Add link support to resource creation and editing, including validation

This commit is contained in:
2025-06-24 20:19:29 +08:00
parent 167cb617b8
commit c44d71b0da
8 changed files with 185 additions and 17 deletions

View File

@@ -128,7 +128,7 @@ export function UploadClipboardImageButton({
); );
} }
export function ImageDrapArea({ export function ImageDropArea({
children, children,
onUploaded, onUploaded,
}: { }: {

View File

@@ -42,6 +42,7 @@ export interface TagWithCount extends Tag {
export interface CreateResourceParams { export interface CreateResourceParams {
title: string; title: string;
alternative_titles: string[]; alternative_titles: string[];
links: RLink[];
tags: number[]; tags: number[];
article: string; article: string;
images: number[]; images: number[];
@@ -53,6 +54,11 @@ export interface Image {
height: number; height: number;
} }
export interface RLink {
label: string;
url: string;
}
export interface Resource { export interface Resource {
id: number; id: number;
title: string; title: string;
@@ -66,6 +72,7 @@ export interface ResourceDetails {
id: number; id: number;
title: string; title: string;
alternativeTitles: string[]; alternativeTitles: string[];
links: RLink[];
article: string; article: string;
createdAt: string; createdAt: string;
tags: Tag[]; tags: Tag[];

View File

@@ -16,7 +16,7 @@ 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 { import {
ImageDrapArea, ImageDropArea,
SelectAndUploadImageButton, SelectAndUploadImageButton,
UploadClipboardImageButton, UploadClipboardImageButton,
} from "../components/image_selector.tsx"; } from "../components/image_selector.tsx";
@@ -27,6 +27,7 @@ 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 [links, setLinks] = useState<{ label: string; url: string }[]>([]);
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);
@@ -53,6 +54,7 @@ export default function EditResourcePage() {
setTags(data.tags); setTags(data.tags);
setArticle(data.article); setArticle(data.article);
setImages(data.images.map((i) => i.id)); setImages(data.images.map((i) => i.id));
setLinks(data.links);
setLoading(false); setLoading(false);
} else { } else {
showToast({ message: t("Failed to load resource"), type: "error" }); showToast({ message: t("Failed to load resource"), type: "error" });
@@ -74,6 +76,12 @@ export default function EditResourcePage() {
return; return;
} }
} }
for (let i = 0; i < links.length; i++) {
if (!links[i].label || !links[i].url) {
setError(t("Link cannot be empty"));
return;
}
}
if (!tags || tags.length === 0) { if (!tags || tags.length === 0) {
setError(t("At least one tag required")); setError(t("At least one tag required"));
return; return;
@@ -89,6 +97,7 @@ export default function EditResourcePage() {
tags: tags.map((tag) => tag.id), tags: tags.map((tag) => tag.id),
article: article, article: article,
images: images, images: images,
links: links,
}); });
if (res.success) { if (res.success) {
setSubmitting(false); setSubmitting(false);
@@ -117,7 +126,7 @@ export default function EditResourcePage() {
} }
return ( return (
<ImageDrapArea <ImageDropArea
onUploaded={(images) => { onUploaded={(images) => {
setImages((prev) => [...prev, ...images]); setImages((prev) => [...prev, ...images]);
}} }}
@@ -175,6 +184,61 @@ export default function EditResourcePage() {
{t("Add Alternative Title")} {t("Add Alternative Title")}
</button> </button>
<div className={"h-2"}></div> <div className={"h-2"}></div>
<p className={"my-1"}>{t("Links")}</p>
<div className={"flex flex-col"}>
{links.map((link, index) => {
return (
<div key={index} className={"flex items-center my-2"}>
<input
type="text"
className="input"
placeholder={t("Label")}
value={link.label}
onChange={(e) => {
const newLinks = [...links];
newLinks[index].label = e.target.value;
setLinks(newLinks);
}}
/>
<input
type="text"
className="input w-full ml-2"
placeholder={t("URL")}
value={link.url}
onChange={(e) => {
const newLinks = [...links];
newLinks[index].url = e.target.value;
setLinks(newLinks);
}}
/>
<button
className={"btn btn-square btn-error ml-2"}
type={"button"}
onClick={() => {
const newLinks = [...links];
newLinks.splice(index, 1);
setLinks(newLinks);
}}
>
<MdDelete size={24} />
</button>
</div>
);
})}
<div className={"flex"}>
<button
className={"btn my-2"}
type={"button"}
onClick={() => {
setLinks([...links, { label: "", url: "" }]);
}}
>
<MdAdd />
{t("Add Link")}
</button>
</div>
</div>
<div className={"h-2"}></div>
<p className={"my-1"}>{t("Tags")}</p> <p className={"my-1"}>{t("Tags")}</p>
<p className={"my-1 pb-1"}> <p className={"my-1 pb-1"}>
{tags.map((tag, index) => { {tags.map((tag, index) => {
@@ -343,6 +407,6 @@ export default function EditResourcePage() {
</button> </button>
</div> </div>
</div> </div>
</ImageDrapArea> </ImageDropArea>
); );
} }

View File

@@ -15,7 +15,7 @@ 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 { import {
ImageDrapArea, ImageDropArea,
SelectAndUploadImageButton, SelectAndUploadImageButton,
UploadClipboardImageButton, UploadClipboardImageButton,
} from "../components/image_selector.tsx"; } from "../components/image_selector.tsx";
@@ -26,6 +26,7 @@ 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 [links, setLinks] = useState<{ label: string; url: string }[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSubmitting, setSubmitting] = useState(false); const [isSubmitting, setSubmitting] = useState(false);
@@ -83,6 +84,12 @@ export default function PublishPage() {
return; return;
} }
} }
for (let i = 0; i < links.length; i++) {
if (!links[i].label || !links[i].url) {
setError(t("Link cannot be empty"));
return;
}
}
if (!tags || tags.length === 0) { if (!tags || tags.length === 0) {
setError(t("At least one tag required")); setError(t("At least one tag required"));
return; return;
@@ -98,6 +105,7 @@ export default function PublishPage() {
tags: tags.map((tag) => tag.id), tags: tags.map((tag) => tag.id),
article: article, article: article,
images: images, images: images,
links: links,
}); });
if (res.success) { if (res.success) {
localStorage.removeItem("publish_data"); localStorage.removeItem("publish_data");
@@ -129,7 +137,7 @@ export default function PublishPage() {
} }
return ( return (
<ImageDrapArea <ImageDropArea
onUploaded={(images) => { onUploaded={(images) => {
setImages((prev) => [...prev, ...images]); setImages((prev) => [...prev, ...images]);
}} }}
@@ -187,6 +195,61 @@ export default function PublishPage() {
{t("Add Alternative Title")} {t("Add Alternative Title")}
</button> </button>
<div className={"h-2"}></div> <div className={"h-2"}></div>
<p className={"my-1"}>{t("Links")}</p>
<div className={"flex flex-col"}>
{links.map((link, index) => {
return (
<div key={index} className={"flex items-center my-2"}>
<input
type="text"
className="input"
placeholder={t("Label")}
value={link.label}
onChange={(e) => {
const newLinks = [...links];
newLinks[index].label = e.target.value;
setLinks(newLinks);
}}
/>
<input
type="text"
className="input w-full ml-2"
placeholder={t("URL")}
value={link.url}
onChange={(e) => {
const newLinks = [...links];
newLinks[index].url = e.target.value;
setLinks(newLinks);
}}
/>
<button
className={"btn btn-square btn-error ml-2"}
type={"button"}
onClick={() => {
const newLinks = [...links];
newLinks.splice(index, 1);
setLinks(newLinks);
}}
>
<MdDelete size={24} />
</button>
</div>
);
})}
<div className={"flex"}>
<button
className={"btn my-2"}
type={"button"}
onClick={() => {
setLinks([...links, { label: "", url: "" }]);
}}
>
<MdAdd />
{t("Add Link")}
</button>
</div>
</div>
<div className={"h-2"}></div>
<p className={"my-1"}>{t("Tags")}</p> <p className={"my-1"}>{t("Tags")}</p>
<p className={"my-1 pb-1"}> <p className={"my-1 pb-1"}>
{tags.map((tag, index) => { {tags.map((tag, index) => {
@@ -355,6 +418,6 @@ export default function PublishPage() {
</button> </button>
</div> </div>
</div> </div>
</ImageDrapArea> </ImageDropArea>
); );
} }

View File

@@ -34,6 +34,7 @@ import {
MdOutlineDownload, MdOutlineDownload,
MdOutlineEdit, MdOutlineEdit,
MdOutlineImage, MdOutlineImage,
MdOutlineLink,
MdOutlineOpenInNew, MdOutlineOpenInNew,
} from "react-icons/md"; } from "react-icons/md";
import { app } from "../app.ts"; import { app } from "../app.ts";
@@ -48,6 +49,7 @@ import Badge, { BadgeAccent } from "../components/badge.tsx";
import Input, { TextArea } from "../components/input.tsx"; import Input, { TextArea } from "../components/input.tsx";
import { useAppContext } from "../components/AppContext.tsx"; import { useAppContext } from "../components/AppContext.tsx";
import { ImageGrid, SquareImage } from "../components/image.tsx"; import { ImageGrid, SquareImage } from "../components/image.tsx";
import { BiLogoSteam } from "react-icons/bi";
export default function ResourcePage() { export default function ResourcePage() {
const params = useParams(); const params = useParams();
@@ -182,6 +184,27 @@ export default function ResourcePage() {
</div> </div>
</button> </button>
<Tags tags={resource.tags} /> <Tags tags={resource.tags} />
{resource.links && (
<p className={"px-3 mt-2"}>
{resource.links.map((l) => {
return (
<span
className={
"py-1 px-3 inline-flex items-center m-1 border border-base-300 rounded-2xl hover:bg-base-200 transition-colors cursor-pointer select-none"
}
>
{l.url.includes("steampowered.com") ? (
<BiLogoSteam size={20} />
) : (
<MdOutlineLink size={20} />
)}
<span className={"ml-2 text-sm"}>{l.label}</span>
</span>
);
})}
</p>
)}
<div className="tabs tabs-box my-4 mx-2 p-4"> <div className="tabs tabs-box my-4 mx-2 p-4">
<label className="tab transition-all"> <label className="tab transition-all">
<input <input

View File

@@ -24,7 +24,7 @@ func updateSiteMapAndRss(baseURL string) {
} }
func handleCreateResource(c fiber.Ctx) error { func handleCreateResource(c fiber.Ctx) error {
var params service.ResourceCreateParams var params service.ResourceParams
body := c.Body() body := c.Body()
err := json.Unmarshal(body, &params) err := json.Unmarshal(body, &params)
if err != nil { if err != nil {
@@ -229,7 +229,7 @@ func handleUpdateResource(c fiber.Ctx) error {
if err != nil { if err != nil {
return model.NewRequestError("Invalid resource ID") return model.NewRequestError("Invalid resource ID")
} }
var params service.ResourceCreateParams var params service.ResourceParams
body := c.Body() body := c.Body()
err = json.Unmarshal(body, &params) err = json.Unmarshal(body, &params)
if err != nil { if err != nil {

View File

@@ -10,6 +10,7 @@ type Resource struct {
gorm.Model gorm.Model
Title string Title string
AlternativeTitles []string `gorm:"serializer:json"` AlternativeTitles []string `gorm:"serializer:json"`
Links []Link `gorm:"serializer:json"`
Article string Article string
Images []Image `gorm:"many2many:resource_images;"` Images []Image `gorm:"many2many:resource_images;"`
Tags []Tag `gorm:"many2many:resource_tags;"` Tags []Tag `gorm:"many2many:resource_tags;"`
@@ -20,6 +21,11 @@ type Resource struct {
Downloads uint Downloads uint
} }
type Link struct {
URL string `json:"url"`
Label string `json:"label"`
}
type ResourceView struct { type ResourceView struct {
ID uint `json:"id"` ID uint `json:"id"`
Title string `json:"title"` Title string `json:"title"`
@@ -33,6 +39,7 @@ type ResourceDetailView struct {
ID uint `json:"id"` ID uint `json:"id"`
Title string `json:"title"` Title string `json:"title"`
AlternativeTitles []string `json:"alternativeTitles"` AlternativeTitles []string `json:"alternativeTitles"`
Links []Link `json:"links"`
Article string `json:"article"` Article string `json:"article"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
Tags []TagView `json:"tags"` Tags []TagView `json:"tags"`
@@ -84,6 +91,7 @@ func (r *Resource) ToDetailView() ResourceDetailView {
ID: r.ID, ID: r.ID,
Title: r.Title, Title: r.Title,
AlternativeTitles: r.AlternativeTitles, AlternativeTitles: r.AlternativeTitles,
Links: r.Links,
Article: r.Article, Article: r.Article,
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
Tags: tags, Tags: tags,

View File

@@ -12,15 +12,16 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
type ResourceCreateParams struct { type ResourceParams struct {
Title string `json:"title" binding:"required"` Title string `json:"title" binding:"required"`
AlternativeTitles []string `json:"alternative_titles"` AlternativeTitles []string `json:"alternative_titles"`
Links []model.Link `json:"links"`
Tags []uint `json:"tags"` Tags []uint `json:"tags"`
Article string `json:"article"` Article string `json:"article"`
Images []uint `json:"images"` Images []uint `json:"images"`
} }
func CreateResource(uid uint, params *ResourceCreateParams) (uint, error) { func CreateResource(uid uint, params *ResourceParams) (uint, error) {
canUpload, err := checkUserCanUpload(uid) canUpload, err := checkUserCanUpload(uid)
if err != nil { if err != nil {
return 0, err return 0, err
@@ -49,6 +50,7 @@ func CreateResource(uid uint, params *ResourceCreateParams) (uint, error) {
Title: params.Title, Title: params.Title,
AlternativeTitles: params.AlternativeTitles, AlternativeTitles: params.AlternativeTitles,
Article: params.Article, Article: params.Article,
Links: params.Links,
Images: images, Images: images,
Tags: tags, Tags: tags,
UserID: uid, UserID: uid,
@@ -213,7 +215,7 @@ func GetResourcesWithUser(username string, page int) ([]model.ResourceView, int,
return views, totalPages, nil return views, totalPages, nil
} }
func EditResource(uid, rid uint, params *ResourceCreateParams) error { func EditResource(uid, rid uint, params *ResourceParams) error {
isAdmin, err := checkUserCanUpload(uid) isAdmin, err := checkUserCanUpload(uid)
if err != nil { if err != nil {
log.Error("checkUserCanUpload error: ", err) log.Error("checkUserCanUpload error: ", err)
@@ -230,6 +232,7 @@ func EditResource(uid, rid uint, params *ResourceCreateParams) error {
r.Title = params.Title r.Title = params.Title
r.AlternativeTitles = params.AlternativeTitles r.AlternativeTitles = params.AlternativeTitles
r.Article = params.Article r.Article = params.Article
r.Links = params.Links
images := make([]model.Image, len(params.Images)) images := make([]model.Image, len(params.Images))
for i, id := range params.Images { for i, id := range params.Images {