mirror of
https://github.com/wgh136/nysoure.git
synced 2025-09-27 04:17:23 +00:00
Add image upload functionality with drag-and-drop and clipboard support
This commit is contained in:
163
frontend/src/components/image_selector.tsx
Normal file
163
frontend/src/components/image_selector.tsx
Normal file
@@ -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<number[]> {
|
||||||
|
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 <button className={"btn my-2"} type={"button"} onClick={addImage}>
|
||||||
|
{isUploading ? <span className="loading loading-spinner"></span> : <MdAdd />}
|
||||||
|
{t("Upload Image")}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <button className={"btn my-2"} type={"button"} onClick={addClipboardImage}>
|
||||||
|
{isUploading ? <span className="loading loading-spinner"></span> : <MdAdd />}
|
||||||
|
{t("Upload Clipboard Image")}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageDrapArea({children, onUploaded}: {children: React.ReactNode, onUploaded: (image: number[]) => void}) {
|
||||||
|
const [isUploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<dialog id="uploading_image_dialog" className="modal">
|
||||||
|
<div className="modal-box">
|
||||||
|
<h3 className="font-bold text-lg">Uploading Image</h3>
|
||||||
|
<div className={"flex items-center justify-center w-full h-40"}>
|
||||||
|
<span className="loading loading-spinner progress-primary loading-lg mr-2"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
<div
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@@ -177,6 +177,7 @@ export const i18nData = {
|
|||||||
"Input tags separated by separator.": "Input tags separated by separator.",
|
"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.",
|
"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.",
|
"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": {
|
"zh-CN": {
|
||||||
@@ -357,6 +358,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": "上传剪贴板图片",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"zh-TW": {
|
"zh-TW": {
|
||||||
@@ -537,6 +539,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": "上傳剪貼板圖片",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -9,6 +9,7 @@ import { app } from "../app.ts";
|
|||||||
import { ErrorAlert } from "../components/alert.tsx";
|
import { ErrorAlert } from "../components/alert.tsx";
|
||||||
import Loading from "../components/loading.tsx";
|
import Loading from "../components/loading.tsx";
|
||||||
import TagInput, {QuickAddTagDialog} from "../components/tag_input.tsx";
|
import TagInput, {QuickAddTagDialog} from "../components/tag_input.tsx";
|
||||||
|
import {ImageDrapArea, SelectAndUploadImageButton, UploadClipboardImageButton} from "../components/image_selector.tsx";
|
||||||
|
|
||||||
export default function EditResourcePage() {
|
export default function EditResourcePage() {
|
||||||
const [title, setTitle] = useState<string>("")
|
const [title, setTitle] = useState<string>("")
|
||||||
@@ -16,7 +17,6 @@ export default function EditResourcePage() {
|
|||||||
const [tags, setTags] = useState<Tag[]>([])
|
const [tags, setTags] = useState<Tag[]>([])
|
||||||
const [article, setArticle] = useState<string>("")
|
const [article, setArticle] = useState<string>("")
|
||||||
const [images, setImages] = useState<number[]>([])
|
const [images, setImages] = useState<number[]>([])
|
||||||
const [isUploading, setUploading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [isSubmitting, setSubmitting] = useState(false)
|
const [isSubmitting, setSubmitting] = useState(false)
|
||||||
const [isLoading, setLoading] = useState(true)
|
const [isLoading, setLoading] = useState(true)
|
||||||
@@ -87,32 +87,6 @@ export default function EditResourcePage() {
|
|||||||
setError(res.message)
|
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)) {
|
if (isNaN(id)) {
|
||||||
return <ErrorAlert className={"m-4"} message={t("Invalid resource ID")} />
|
return <ErrorAlert className={"m-4"} message={t("Invalid resource ID")} />
|
||||||
@@ -126,154 +100,163 @@ export default function EditResourcePage() {
|
|||||||
return <Loading/>
|
return <Loading/>
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className={"p-4"}>
|
return <ImageDrapArea onUploaded={(images) => {
|
||||||
<h1 className={"text-2xl font-bold my-4"}>{t("Edit Resource")}</h1>
|
setImages((prev) => ([...prev, ...images]));
|
||||||
<div role="alert" className="alert alert-info mb-2 alert-dash">
|
}}>
|
||||||
<MdOutlineInfo size={24} />
|
<div className={"p-4"}>
|
||||||
<span>{t("All information can be modified after publishing")}</span>
|
<h1 className={"text-2xl font-bold my-4"}>{t("Edit Resource")}</h1>
|
||||||
</div>
|
<div role="alert" className="alert alert-info mb-2 alert-dash">
|
||||||
<p className={"my-1"}>{t("Title")}</p>
|
<MdOutlineInfo size={24} />
|
||||||
<input type="text" className="input w-full" value={title} onChange={(e) => setTitle(e.target.value)} />
|
<span>{t("All information can be modified after publishing")}</span>
|
||||||
<div className={"h-4"}></div>
|
</div>
|
||||||
<p className={"my-1"}>{t("Alternative Titles")}</p>
|
<p className={"my-1"}>{t("Title")}</p>
|
||||||
{
|
<input type="text" className="input w-full" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
altTitles.map((title, index) => {
|
<div className={"h-4"}></div>
|
||||||
return <div key={index} className={"flex items-center my-2"}>
|
<p className={"my-1"}>{t("Alternative Titles")}</p>
|
||||||
<input type="text" className="input w-full" value={title} onChange={(e) => {
|
|
||||||
const newAltTitles = [...altTitles]
|
|
||||||
newAltTitles[index] = e.target.value
|
|
||||||
setAltTitles(newAltTitles)
|
|
||||||
}} />
|
|
||||||
<button className={"btn btn-square btn-error ml-2"} type={"button"} onClick={() => {
|
|
||||||
const newAltTitles = [...altTitles]
|
|
||||||
newAltTitles.splice(index, 1)
|
|
||||||
setAltTitles(newAltTitles)
|
|
||||||
}}>
|
|
||||||
<MdDelete size={24} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
<button className={"btn my-2"} type={"button"} onClick={() => {
|
|
||||||
setAltTitles([...altTitles, ""])
|
|
||||||
}}>
|
|
||||||
<MdAdd />
|
|
||||||
{t("Add Alternative Title")}
|
|
||||||
</button>
|
|
||||||
<div className={"h-2"}></div>
|
|
||||||
<p className={"my-1"}>{t("Tags")}</p>
|
|
||||||
<p className={"my-1 pb-1"}>
|
|
||||||
{
|
{
|
||||||
tags.map((tag, index) => {
|
altTitles.map((title, index) => {
|
||||||
return <span key={index} className={"badge badge-primary mr-2 text-sm"}>
|
return <div key={index} className={"flex items-center my-2"}>
|
||||||
{tag.name}
|
<input type="text" className="input w-full" value={title} onChange={(e) => {
|
||||||
<span onClick={() => {
|
const newAltTitles = [...altTitles]
|
||||||
const newTags = [...tags]
|
newAltTitles[index] = e.target.value
|
||||||
newTags.splice(index, 1)
|
setAltTitles(newAltTitles)
|
||||||
setTags(newTags)
|
}} />
|
||||||
|
<button className={"btn btn-square btn-error ml-2"} type={"button"} onClick={() => {
|
||||||
|
const newAltTitles = [...altTitles]
|
||||||
|
newAltTitles.splice(index, 1)
|
||||||
|
setAltTitles(newAltTitles)
|
||||||
}}>
|
}}>
|
||||||
|
<MdDelete size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
<button className={"btn my-2"} type={"button"} onClick={() => {
|
||||||
|
setAltTitles([...altTitles, ""])
|
||||||
|
}}>
|
||||||
|
<MdAdd />
|
||||||
|
{t("Add Alternative Title")}
|
||||||
|
</button>
|
||||||
|
<div className={"h-2"}></div>
|
||||||
|
<p className={"my-1"}>{t("Tags")}</p>
|
||||||
|
<p className={"my-1 pb-1"}>
|
||||||
|
{
|
||||||
|
tags.map((tag, index) => {
|
||||||
|
return <span key={index} className={"badge badge-primary mr-2 text-sm"}>
|
||||||
|
{tag.name}
|
||||||
|
<span onClick={() => {
|
||||||
|
const newTags = [...tags]
|
||||||
|
newTags.splice(index, 1)
|
||||||
|
setTags(newTags)
|
||||||
|
}}>
|
||||||
<MdClose size={18}/>
|
<MdClose size={18}/>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
})
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
<div className={"flex items-center"}>
|
|
||||||
<TagInput onAdd={(tag) => {
|
|
||||||
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];
|
|
||||||
})
|
|
||||||
}} />
|
|
||||||
<span className={"w-4"}/>
|
|
||||||
<QuickAddTagDialog onAdded={(tags) => {
|
|
||||||
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;
|
|
||||||
})
|
|
||||||
}}/>
|
|
||||||
</div>
|
|
||||||
<div className={"h-4"}></div>
|
|
||||||
<p className={"my-1"}>{t("Description")}</p>
|
|
||||||
<textarea className="textarea w-full min-h-80 p-4" value={article} onChange={(e) => setArticle(e.target.value)} />
|
|
||||||
<div className={"flex items-center py-1 "}>
|
|
||||||
<MdOutlineInfo className={"inline mr-1"} />
|
|
||||||
<span className={"text-sm"}>{t("Use Markdown format")}</span>
|
|
||||||
</div>
|
|
||||||
<div className={"h-4"}></div>
|
|
||||||
<p className={"my-1"}>{t("Images")}</p>
|
|
||||||
<div role="alert" className="alert alert-info alert-soft my-2">
|
|
||||||
<MdOutlineInfo size={24} />
|
|
||||||
<div>
|
|
||||||
<p>{t("Images will not be displayed automatically, you need to reference them in the description")}</p>
|
|
||||||
<p>{t("The first image will be used as the cover image")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={`rounded-box border border-base-content/5 bg-base-100 ${images.length === 0 ? "hidden" : ""}`}>
|
|
||||||
<table className={"table"}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<td>{t("Preview")}</td>
|
|
||||||
<td>{t("Link")}</td>
|
|
||||||
<td>{t("Action")}</td>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{
|
|
||||||
images.map((image, index) => {
|
|
||||||
return <tr key={index} className={"hover"}>
|
|
||||||
<td>
|
|
||||||
<img src={network.getImageUrl(image)} className={"w-16 h-16 object-cover card"} alt={"image"} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{network.getImageUrl(image)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button className={"btn btn-square"} type={"button"} onClick={() => {
|
|
||||||
const id = images[index]
|
|
||||||
const newImages = [...images]
|
|
||||||
newImages.splice(index, 1)
|
|
||||||
setImages(newImages)
|
|
||||||
network.deleteImage(id)
|
|
||||||
}}>
|
|
||||||
<MdDelete size={24} />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</tbody>
|
</p>
|
||||||
</table>
|
<div className={"flex items-center"}>
|
||||||
</div>
|
<TagInput onAdd={(tag) => {
|
||||||
<button className={"btn my-2"} type={"button"} onClick={addImage}>
|
setTags((prev) => {
|
||||||
{isUploading ? <span className="loading loading-spinner"></span> : <MdAdd />}
|
const existingTag = prev.find(t => t.id === tag.id);
|
||||||
{t("Upload Image")}
|
if (existingTag) {
|
||||||
</button>
|
return prev; // If the tag already exists, do not add it again
|
||||||
<div className={"h-4"}></div>
|
}
|
||||||
{
|
return [...prev, tag];
|
||||||
error && <div role="alert" className="alert alert-error my-2 shadow">
|
})
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none"
|
}} />
|
||||||
viewBox="0 0 24 24">
|
<span className={"w-4"}/>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<QuickAddTagDialog onAdded={(tags) => {
|
||||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
setTags((prev) => {
|
||||||
</svg>
|
const newTags = [...prev];
|
||||||
<span>{t("Error")}: {error}</span>
|
for (const tag of tags) {
|
||||||
|
const existingTag = newTags.find(t => t.id === tag.id);
|
||||||
|
if (!existingTag) {
|
||||||
|
newTags.push(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newTags;
|
||||||
|
})
|
||||||
|
}}/>
|
||||||
|
</div>
|
||||||
|
<div className={"h-4"}></div>
|
||||||
|
<p className={"my-1"}>{t("Description")}</p>
|
||||||
|
<textarea className="textarea w-full min-h-80 p-4" value={article} onChange={(e) => setArticle(e.target.value)} />
|
||||||
|
<div className={"flex items-center py-1 "}>
|
||||||
|
<MdOutlineInfo className={"inline mr-1"} />
|
||||||
|
<span className={"text-sm"}>{t("Use Markdown format")}</span>
|
||||||
|
</div>
|
||||||
|
<div className={"h-4"}></div>
|
||||||
|
<p className={"my-1"}>{t("Images")}</p>
|
||||||
|
<div role="alert" className="alert alert-info alert-soft my-2">
|
||||||
|
<MdOutlineInfo size={24} />
|
||||||
|
<div>
|
||||||
|
<p>{t("Images will not be displayed automatically, you need to reference them in the description")}</p>
|
||||||
|
<p>{t("The first image will be used as the cover image")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`rounded-box border border-base-content/5 bg-base-100 ${images.length === 0 ? "hidden" : ""}`}>
|
||||||
|
<table className={"table"}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td>{t("Preview")}</td>
|
||||||
|
<td>{t("Link")}</td>
|
||||||
|
<td>{t("Action")}</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{
|
||||||
|
images.map((image, index) => {
|
||||||
|
return <tr key={index} className={"hover"}>
|
||||||
|
<td>
|
||||||
|
<img src={network.getImageUrl(image)} className={"w-16 h-16 object-cover card"} alt={"image"} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{network.getImageUrl(image)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className={"btn btn-square"} type={"button"} onClick={() => {
|
||||||
|
const id = images[index]
|
||||||
|
const newImages = [...images]
|
||||||
|
newImages.splice(index, 1)
|
||||||
|
setImages(newImages)
|
||||||
|
network.deleteImage(id)
|
||||||
|
}}>
|
||||||
|
<MdDelete size={24} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className={"flex"}>
|
||||||
|
<SelectAndUploadImageButton onUploaded={(images) => {
|
||||||
|
setImages((prev) => ([...prev, ...images]));
|
||||||
|
}}/>
|
||||||
|
<span className={"w-4"}></span>
|
||||||
|
<UploadClipboardImageButton onUploaded={(images) => {
|
||||||
|
setImages((prev) => ([...prev, ...images]));
|
||||||
|
}}/>
|
||||||
|
</div>
|
||||||
|
<div className={"h-4"}></div>
|
||||||
|
{
|
||||||
|
error && <div role="alert" className="alert alert-error my-2 shadow">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{t("Error")}: {error}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div className={"flex flex-row-reverse mt-4"}>
|
||||||
|
<button className={"btn btn-accent shadow"} onClick={handleSubmit}>
|
||||||
|
{isSubmitting && <span className="loading loading-spinner"></span>}
|
||||||
|
{t("Publish")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
<div className={"flex flex-row-reverse mt-4"}>
|
|
||||||
<button className={"btn btn-accent shadow"} onClick={handleSubmit}>
|
|
||||||
{isSubmitting && <span className="loading loading-spinner"></span>}
|
|
||||||
{t("Publish")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ImageDrapArea>
|
||||||
}
|
}
|
||||||
|
@@ -3,12 +3,12 @@ import {MdAdd, MdClose, MdDelete, MdOutlineInfo} from "react-icons/md";
|
|||||||
import { Tag } from "../network/models.ts";
|
import { Tag } from "../network/models.ts";
|
||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import showToast from "../components/toast.ts";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { app } from "../app.ts";
|
import { app } from "../app.ts";
|
||||||
import { ErrorAlert } from "../components/alert.tsx";
|
import { ErrorAlert } from "../components/alert.tsx";
|
||||||
import {useAppContext} from "../components/AppContext.tsx";
|
import {useAppContext} from "../components/AppContext.tsx";
|
||||||
import TagInput, {QuickAddTagDialog} from "../components/tag_input.tsx";
|
import TagInput, {QuickAddTagDialog} from "../components/tag_input.tsx";
|
||||||
|
import {ImageDrapArea, SelectAndUploadImageButton, UploadClipboardImageButton} from "../components/image_selector.tsx";
|
||||||
|
|
||||||
export default function PublishPage() {
|
export default function PublishPage() {
|
||||||
const [title, setTitle] = useState<string>("")
|
const [title, setTitle] = useState<string>("")
|
||||||
@@ -16,7 +16,6 @@ export default function PublishPage() {
|
|||||||
const [tags, setTags] = useState<Tag[]>([])
|
const [tags, setTags] = useState<Tag[]>([])
|
||||||
const [article, setArticle] = useState<string>("")
|
const [article, setArticle] = useState<string>("")
|
||||||
const [images, setImages] = useState<number[]>([])
|
const [images, setImages] = useState<number[]>([])
|
||||||
const [isUploading, setUploading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [isSubmitting, setSubmitting] = useState(false)
|
const [isSubmitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
@@ -68,32 +67,6 @@ export default function PublishPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (!app.user) {
|
if (!app.user) {
|
||||||
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
|
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
|
||||||
}
|
}
|
||||||
@@ -102,108 +75,111 @@ export default function PublishPage() {
|
|||||||
return <ErrorAlert className={"m-4"} message={t("You are not authorized to access this page.")} />
|
return <ErrorAlert className={"m-4"} message={t("You are not authorized to access this page.")} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className={"p-4"}>
|
return <ImageDrapArea onUploaded={(images) => {
|
||||||
<h1 className={"text-2xl font-bold my-4"}>{t("Publish Resource")}</h1>
|
setImages((prev) => ([...prev, ...images]));
|
||||||
<div role="alert" className="alert alert-info mb-2 alert-dash">
|
}}>
|
||||||
<MdOutlineInfo size={24} />
|
<div className={"p-4"}>
|
||||||
<span>{t("All information can be modified after publishing")}</span>
|
<h1 className={"text-2xl font-bold my-4"}>{t("Publish Resource")}</h1>
|
||||||
</div>
|
<div role="alert" className="alert alert-info mb-2 alert-dash">
|
||||||
<p className={"my-1"}>{t("Title")}</p>
|
<MdOutlineInfo size={24} />
|
||||||
<input type="text" className="input w-full" value={title} onChange={(e) => setTitle(e.target.value)} />
|
<span>{t("All information can be modified after publishing")}</span>
|
||||||
<div className={"h-4"}></div>
|
</div>
|
||||||
<p className={"my-1"}>{t("Alternative Titles")}</p>
|
<p className={"my-1"}>{t("Title")}</p>
|
||||||
{
|
<input type="text" className="input w-full" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
altTitles.map((title, index) => {
|
<div className={"h-4"}></div>
|
||||||
return <div key={index} className={"flex items-center my-2"}>
|
<p className={"my-1"}>{t("Alternative Titles")}</p>
|
||||||
<input type="text" className="input w-full" value={title} onChange={(e) => {
|
|
||||||
const newAltTitles = [...altTitles]
|
|
||||||
newAltTitles[index] = e.target.value
|
|
||||||
setAltTitles(newAltTitles)
|
|
||||||
}} />
|
|
||||||
<button className={"btn btn-square btn-error ml-2"} type={"button"} onClick={() => {
|
|
||||||
const newAltTitles = [...altTitles]
|
|
||||||
newAltTitles.splice(index, 1)
|
|
||||||
setAltTitles(newAltTitles)
|
|
||||||
}}>
|
|
||||||
<MdDelete size={24} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
<button className={"btn my-2"} type={"button"} onClick={() => {
|
|
||||||
setAltTitles([...altTitles, ""])
|
|
||||||
}}>
|
|
||||||
<MdAdd />
|
|
||||||
{t("Add Alternative Title")}
|
|
||||||
</button>
|
|
||||||
<div className={"h-2"}></div>
|
|
||||||
<p className={"my-1"}>{t("Tags")}</p>
|
|
||||||
<p className={"my-1 pb-1"}>
|
|
||||||
{
|
{
|
||||||
tags.map((tag, index) => {
|
altTitles.map((title, index) => {
|
||||||
return <span key={index} className={"badge badge-primary mr-2 text-sm"}>
|
return <div key={index} className={"flex items-center my-2"}>
|
||||||
{tag.name}
|
<input type="text" className="input w-full" value={title} onChange={(e) => {
|
||||||
<span onClick={() => {
|
const newAltTitles = [...altTitles]
|
||||||
const newTags = [...tags]
|
newAltTitles[index] = e.target.value
|
||||||
newTags.splice(index, 1)
|
setAltTitles(newAltTitles)
|
||||||
setTags(newTags)
|
}} />
|
||||||
|
<button className={"btn btn-square btn-error ml-2"} type={"button"} onClick={() => {
|
||||||
|
const newAltTitles = [...altTitles]
|
||||||
|
newAltTitles.splice(index, 1)
|
||||||
|
setAltTitles(newAltTitles)
|
||||||
}}>
|
}}>
|
||||||
|
<MdDelete size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
<button className={"btn my-2"} type={"button"} onClick={() => {
|
||||||
|
setAltTitles([...altTitles, ""])
|
||||||
|
}}>
|
||||||
|
<MdAdd />
|
||||||
|
{t("Add Alternative Title")}
|
||||||
|
</button>
|
||||||
|
<div className={"h-2"}></div>
|
||||||
|
<p className={"my-1"}>{t("Tags")}</p>
|
||||||
|
<p className={"my-1 pb-1"}>
|
||||||
|
{
|
||||||
|
tags.map((tag, index) => {
|
||||||
|
return <span key={index} className={"badge badge-primary mr-2 text-sm"}>
|
||||||
|
{tag.name}
|
||||||
|
<span onClick={() => {
|
||||||
|
const newTags = [...tags]
|
||||||
|
newTags.splice(index, 1)
|
||||||
|
setTags(newTags)
|
||||||
|
}}>
|
||||||
<MdClose size={18}/>
|
<MdClose size={18}/>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
<div className={"flex items-center"}>
|
<div className={"flex items-center"}>
|
||||||
<TagInput onAdd={(tag) => {
|
<TagInput onAdd={(tag) => {
|
||||||
setTags((prev) => {
|
setTags((prev) => {
|
||||||
const existingTag = prev.find(t => t.id === tag.id);
|
const existingTag = prev.find(t => t.id === tag.id);
|
||||||
if (existingTag) {
|
if (existingTag) {
|
||||||
return prev; // If the tag already exists, do not add it again
|
return prev; // If the tag already exists, do not add it again
|
||||||
}
|
|
||||||
return [...prev, tag];
|
|
||||||
})
|
|
||||||
}} />
|
|
||||||
<span className={"w-4"}/>
|
|
||||||
<QuickAddTagDialog onAdded={(tags) => {
|
|
||||||
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 [...prev, tag];
|
||||||
return newTags;
|
})
|
||||||
})
|
}} />
|
||||||
}}/>
|
<span className={"w-4"}/>
|
||||||
</div>
|
<QuickAddTagDialog onAdded={(tags) => {
|
||||||
<div className={"h-4"}></div>
|
setTags((prev) => {
|
||||||
<p className={"my-1"}>{t("Description")}</p>
|
const newTags = [...prev];
|
||||||
<textarea className="textarea w-full min-h-80 p-4" value={article} onChange={(e) => setArticle(e.target.value)} />
|
for (const tag of tags) {
|
||||||
<div className={"flex items-center py-1 "}>
|
const existingTag = newTags.find(t => t.id === tag.id);
|
||||||
<MdOutlineInfo className={"inline mr-1"} />
|
if (!existingTag) {
|
||||||
<span className={"text-sm"}>{t("Use Markdown format")}</span>
|
newTags.push(tag);
|
||||||
</div>
|
}
|
||||||
<div className={"h-4"}></div>
|
}
|
||||||
<p className={"my-1"}>{t("Images")}</p>
|
return newTags;
|
||||||
<div role="alert" className="alert alert-info alert-soft my-2">
|
})
|
||||||
<MdOutlineInfo size={24} />
|
}}/>
|
||||||
<div>
|
|
||||||
<p>{t("Images will not be displayed automatically, you need to reference them in the description")}</p>
|
|
||||||
<p>{t("The first image will be used as the cover image")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className={"h-4"}></div>
|
||||||
<div className={`rounded-box border border-base-content/5 bg-base-100 ${images.length === 0 ? "hidden" : ""}`}>
|
<p className={"my-1"}>{t("Description")}</p>
|
||||||
<table className={"table"}>
|
<textarea className="textarea w-full min-h-80 p-4" value={article} onChange={(e) => setArticle(e.target.value)} />
|
||||||
<thead>
|
<div className={"flex items-center py-1 "}>
|
||||||
|
<MdOutlineInfo className={"inline mr-1"} />
|
||||||
|
<span className={"text-sm"}>{t("Use Markdown format")}</span>
|
||||||
|
</div>
|
||||||
|
<div className={"h-4"}></div>
|
||||||
|
<p className={"my-1"}>{t("Images")}</p>
|
||||||
|
<div role="alert" className="alert alert-info alert-soft my-2">
|
||||||
|
<MdOutlineInfo size={24} />
|
||||||
|
<div>
|
||||||
|
<p>{t("Images will not be displayed automatically, you need to reference them in the description")}</p>
|
||||||
|
<p>{t("The first image will be used as the cover image")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`rounded-box border border-base-content/5 bg-base-100 ${images.length === 0 ? "hidden" : ""}`}>
|
||||||
|
<table className={"table"}>
|
||||||
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{t("Preview")}</td>
|
<td>{t("Preview")}</td>
|
||||||
<td>{t("Link")}</td>
|
<td>{t("Link")}</td>
|
||||||
<td>{t("Action")}</td>
|
<td>{t("Action")}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{
|
{
|
||||||
images.map((image, index) => {
|
images.map((image, index) => {
|
||||||
return <tr key={index} className={"hover"}>
|
return <tr key={index} className={"hover"}>
|
||||||
@@ -227,29 +203,35 @@ export default function PublishPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<button className={"btn my-2"} type={"button"} onClick={addImage}>
|
<div className={"flex"}>
|
||||||
{isUploading ? <span className="loading loading-spinner"></span> : <MdAdd />}
|
<SelectAndUploadImageButton onUploaded={(images) => {
|
||||||
{t("Upload Image")}
|
setImages((prev) => ([...prev, ...images]));
|
||||||
</button>
|
}}/>
|
||||||
<div className={"h-4"}></div>
|
<span className={"w-4"}></span>
|
||||||
{
|
<UploadClipboardImageButton onUploaded={(images) => {
|
||||||
error && <div role="alert" className="alert alert-error my-2 shadow">
|
setImages((prev) => ([...prev, ...images]));
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none"
|
}}/>
|
||||||
viewBox="0 0 24 24">
|
</div>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<div className={"h-4"}></div>
|
||||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
{
|
||||||
</svg>
|
error && <div role="alert" className="alert alert-error my-2 shadow">
|
||||||
<span>{t("Error")}: {error}</span>
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{t("Error")}: {error}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div className={"flex flex-row-reverse mt-4"}>
|
||||||
|
<button className={"btn btn-accent shadow"} onClick={handleSubmit}>
|
||||||
|
{isSubmitting && <span className="loading loading-spinner"></span>}
|
||||||
|
{t("Publish")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
<div className={"flex flex-row-reverse mt-4"}>
|
|
||||||
<button className={"btn btn-accent shadow"} onClick={handleSubmit}>
|
|
||||||
{isSubmitting && <span className="loading loading-spinner"></span>}
|
|
||||||
{t("Publish")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ImageDrapArea>
|
||||||
}
|
}
|
Reference in New Issue
Block a user