From 789fb86109b1e9e90ba44a13ff6b61ef00b2ae8d Mon Sep 17 00:00:00 2001 From: nyne Date: Sat, 31 May 2025 18:08:24 +0800 Subject: [PATCH] Add image upload functionality with drag-and-drop and clipboard support --- frontend/src/components/image_selector.tsx | 163 +++++++++++ frontend/src/i18n.ts | 3 + frontend/src/pages/edit_resource_page.tsx | 319 ++++++++++----------- frontend/src/pages/publish_page.tsx | 264 ++++++++--------- 4 files changed, 440 insertions(+), 309 deletions(-) create mode 100644 frontend/src/components/image_selector.tsx diff --git a/frontend/src/components/image_selector.tsx b/frontend/src/components/image_selector.tsx new file mode 100644 index 0000000..3e80465 --- /dev/null +++ b/frontend/src/components/image_selector.tsx @@ -0,0 +1,163 @@ +import {MdAdd} from "react-icons/md"; +import {useTranslation} from "react-i18next"; +import {network} from "../network/network.ts"; +import showToast from "./toast.ts"; +import {useState} from "react"; + +async function uploadImages(files: File[]): Promise { + const images: number[] = []; + + for (const file of files) { + const res = await network.uploadImage(file); + if (res.success) { + images.push(res.data!); + } else { + showToast({ + type: "error", + message: `Failed to upload image: ${res.message}`, + }) + } + } + + return images; +} + +export function SelectAndUploadImageButton({onUploaded}: {onUploaded: (image: number[]) => void}) { + const [isUploading, setUploading] = useState(false) + + const { t } = useTranslation(); + + const addImage = () => { + if (isUploading) { + return + } + const input = document.createElement("input") + input.type = "file" + input.accept = "image/*" + input.multiple = true + input.onchange = async () => { + if (!input.files || input.files.length === 0) { + return + } + setUploading(true) + const files = Array.from(input.files); + const uploadedImages = await uploadImages(files); + setUploading(false); + if (uploadedImages.length > 0) { + onUploaded(uploadedImages); + } + } + input.click() + } + + return +} + +export function UploadClipboardImageButton({onUploaded}: {onUploaded: (image: number[]) => void}) { + const [isUploading, setUploading] = useState(false) + + const { t } = useTranslation(); + + const addClipboardImage = async () => { + if (isUploading) { + return + } + try { + const clipboardItems = await navigator.clipboard.read(); + const files: File[] = []; + for (const item of clipboardItems) { + console.log(item) + for (const type of item.types) { + if (type.startsWith("image/")) { + const blob = await item.getType(type); + files.push(new File([blob], `clipboard-image.${type.split("/")[1]}`, { type })); + } + } + } + if (files.length > 0) { + setUploading(true); + const uploadedImages = await uploadImages(files); + setUploading(false); + if (uploadedImages.length > 0) { + onUploaded(uploadedImages); + } + } else { + showToast({ + type: "error", + message: t("No image found in clipboard"), + }); + } + } catch (error) { + showToast({ + type: "error", + message: t("Failed to read clipboard image"), + }); + } + } + + return +} + +export function ImageDrapArea({children, onUploaded}: {children: React.ReactNode, onUploaded: (image: number[]) => void}) { + const [isUploading, setUploading] = useState(false); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (isUploading) { + return; + } + + if (e.dataTransfer.files.length > 0) { + setUploading(true); + let files = Array.from(e.dataTransfer.files); + files = files.filter(file => file.type.startsWith("image/")); + if (files.length === 0) { + setUploading(false); + return; + } + const uploadedImages = await uploadImages(files); + if (uploadedImages.length > 0) { + onUploaded(uploadedImages); + } + setUploading(false); + } + }; + + return ( + <> + +
+

Uploading Image

+
+ +
+
+
+
+ {children} +
+ + ); +} \ No newline at end of file diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 6490e73..4b4cbe2 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -177,6 +177,7 @@ export const i18nData = { "Input tags separated by separator.": "Input tags separated by separator.", "If the tag does not exist, it will be created automatically.": "If the tag does not exist, it will be created automatically.", "Optionally, you can specify a type for the new tags.": "Optionally, you can specify a type for the new tags.", + "Upload Clipboard Image": "Upload Clipboard Image", } }, "zh-CN": { @@ -357,6 +358,7 @@ export const i18nData = { "Input tags separated by separator.": "输入标签, 用分隔符分隔。", "If the tag does not exist, it will be created automatically.": "如果标签不存在, 将自动创建。", "Optionally, you can specify a type for the new tags.": "您可以选择为新标签指定一个类型。", + "Upload Clipboard Image": "上传剪贴板图片", } }, "zh-TW": { @@ -537,6 +539,7 @@ export const i18nData = { "Input tags separated by separator.": "輸入標籤, 用分隔符分隔。", "If the tag does not exist, it will be created automatically.": "如果標籤不存在, 將自動創建。", "Optionally, you can specify a type for the new tags.": "您可以選擇為新標籤指定一個類型。", + "Upload Clipboard Image": "上傳剪貼板圖片", } } } diff --git a/frontend/src/pages/edit_resource_page.tsx b/frontend/src/pages/edit_resource_page.tsx index 50345e2..1b8a826 100644 --- a/frontend/src/pages/edit_resource_page.tsx +++ b/frontend/src/pages/edit_resource_page.tsx @@ -9,6 +9,7 @@ import { app } from "../app.ts"; import { ErrorAlert } from "../components/alert.tsx"; import Loading from "../components/loading.tsx"; import TagInput, {QuickAddTagDialog} from "../components/tag_input.tsx"; +import {ImageDrapArea, SelectAndUploadImageButton, UploadClipboardImageButton} from "../components/image_selector.tsx"; export default function EditResourcePage() { const [title, setTitle] = useState("") @@ -16,7 +17,6 @@ export default function EditResourcePage() { const [tags, setTags] = useState([]) const [article, setArticle] = useState("") const [images, setImages] = useState([]) - const [isUploading, setUploading] = useState(false) const [error, setError] = useState(null) const [isSubmitting, setSubmitting] = useState(false) const [isLoading, setLoading] = useState(true) @@ -87,32 +87,6 @@ export default function EditResourcePage() { setError(res.message) } } - - const addImage = () => { - if (isUploading) { - return - } - const input = document.createElement("input") - input.type = "file" - input.accept = "image/*" - input.onchange = async () => { - const files = input.files - if (!files || files.length === 0) { - return - } - const image = files[0] - setUploading(true) - const res = await network.uploadImage(image) - if (res.success) { - setUploading(false) - setImages([...images, res.data!]) - } else { - setUploading(false) - showToast({ message: t("Failed to upload image"), type: "error" }) - } - } - input.click() - } if (isNaN(id)) { return @@ -126,154 +100,163 @@ export default function EditResourcePage() { return } - return
-

{t("Edit Resource")}

-
- - {t("All information can be modified after publishing")} -
-

{t("Title")}

- setTitle(e.target.value)} /> -
-

{t("Alternative Titles")}

- { - altTitles.map((title, index) => { - return
- { - const newAltTitles = [...altTitles] - newAltTitles[index] = e.target.value - setAltTitles(newAltTitles) - }} /> - -
- }) - } - -
-

{t("Tags")}

-

+ return { + setImages((prev) => ([...prev, ...images])); + }}> +

+

{t("Edit Resource")}

+
+ + {t("All information can be modified after publishing")} +
+

{t("Title")}

+ setTitle(e.target.value)} /> +
+

{t("Alternative Titles")}

{ - tags.map((tag, index) => { - return - {tag.name} - { - const newTags = [...tags] - newTags.splice(index, 1) - setTags(newTags) + altTitles.map((title, index) => { + return
+ { + const newAltTitles = [...altTitles] + newAltTitles[index] = e.target.value + setAltTitles(newAltTitles) + }} /> + +
+ }) + } + +
+

{t("Tags")}

+

+ { + tags.map((tag, index) => { + return + {tag.name} + { + const newTags = [...tags] + newTags.splice(index, 1) + setTags(newTags) + }}> - }) - } -

-
- { - setTags((prev) => { - const existingTag = prev.find(t => t.id === tag.id); - if (existingTag) { - return prev; // If the tag already exists, do not add it again - } - return [...prev, tag]; - }) - }} /> - - { - setTags((prev) => { - const newTags = [...prev]; - for (const tag of tags) { - const existingTag = newTags.find(t => t.id === tag.id); - if (!existingTag) { - newTags.push(tag); - } - } - return newTags; - }) - }}/> -
-
-

{t("Description")}

-