mirror of
https://github.com/wgh136/nysoure.git
synced 2025-09-27 20:27:23 +00:00
Add tag alias management functionality
This commit is contained in:
@@ -435,6 +435,24 @@ class Network {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setTagAlias(tagID: number, aliases: string[]): Promise<Response<Tag>> {
|
||||||
|
try {
|
||||||
|
const response = await axios.put(
|
||||||
|
`${this.apiBaseUrl}/tag/${tagID}/alias`,
|
||||||
|
{
|
||||||
|
aliases,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: e.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload image and return the image id
|
* Upload image and return the image id
|
||||||
*/
|
*/
|
||||||
|
@@ -12,6 +12,7 @@ import Input, { TextArea } from "../components/input.tsx";
|
|||||||
import TagInput from "../components/tag_input.tsx";
|
import TagInput from "../components/tag_input.tsx";
|
||||||
import Badge from "../components/badge.tsx";
|
import Badge from "../components/badge.tsx";
|
||||||
import { useAppContext } from "../components/AppContext.tsx";
|
import { useAppContext } from "../components/AppContext.tsx";
|
||||||
|
import { MdAdd, MdClose, MdEdit } from "react-icons/md";
|
||||||
|
|
||||||
export default function TaggedResourcesPage() {
|
export default function TaggedResourcesPage() {
|
||||||
const { tag: tagName } = useParams();
|
const { tag: tagName } = useParams();
|
||||||
@@ -45,8 +46,8 @@ export default function TaggedResourcesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={"flex items-center"}>
|
<div className={"flex items-center px-4"}>
|
||||||
<h1 className={"text-2xl pt-6 pb-2 px-4 font-bold flex-1"}>
|
<h1 className={"text-2xl pt-6 pb-2 font-bold flex-1"}>
|
||||||
{tag?.name ?? tagName}
|
{tag?.name ?? tagName}
|
||||||
</h1>
|
</h1>
|
||||||
{tag && app.canUpload() && (
|
{tag && app.canUpload() && (
|
||||||
@@ -67,6 +68,9 @@ export default function TaggedResourcesPage() {
|
|||||||
{(tag?.aliases ?? []).map((e) => {
|
{(tag?.aliases ?? []).map((e) => {
|
||||||
return <Badge className={"m-1 badge-primary badge-soft"}>{e}</Badge>;
|
return <Badge className={"m-1 badge-primary badge-soft"}>{e}</Badge>;
|
||||||
})}
|
})}
|
||||||
|
{app.canUpload() && tag && (
|
||||||
|
<EditAliasDialog tag={tag} onEdited={setTag} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{tag?.description && (
|
{tag?.description && (
|
||||||
<article className={"px-4 py-2"}>
|
<article className={"px-4 py-2"}>
|
||||||
@@ -216,3 +220,138 @@ function EditTagButton({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EditAliasDialog({
|
||||||
|
tag,
|
||||||
|
onEdited,
|
||||||
|
}: {
|
||||||
|
tag: Tag;
|
||||||
|
onEdited?: (t: Tag) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [alias, setAlias] = useState<string[]>(() => {
|
||||||
|
return tag.aliases ?? [];
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// compare alias and tag.aliases
|
||||||
|
let isModified = false;
|
||||||
|
if (alias.length !== tag.aliases?.length) {
|
||||||
|
isModified = true;
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < alias.length; i++) {
|
||||||
|
if (alias[i] !== tag.aliases![i]) {
|
||||||
|
isModified = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isModified) {
|
||||||
|
setError(t("No changes made"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
const res = await network.setTagAlias(tag.id, alias);
|
||||||
|
setIsLoading(false);
|
||||||
|
if (res.success) {
|
||||||
|
const dialog = document.getElementById(
|
||||||
|
"edit_alias_dialog",
|
||||||
|
) as HTMLDialogElement;
|
||||||
|
dialog.close();
|
||||||
|
if (onEdited) {
|
||||||
|
onEdited(res.data!);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(res.message || t("Unknown error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
"m-1 badge-accent badge-soft cursor-pointer hover:shadow-sm transition-shadow"
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setError(null);
|
||||||
|
setAlias(tag.aliases ?? []);
|
||||||
|
const dialog = document.getElementById(
|
||||||
|
"edit_alias_dialog",
|
||||||
|
) as HTMLDialogElement;
|
||||||
|
dialog.showModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdEdit />
|
||||||
|
<span>{t("Edit")}</span>
|
||||||
|
</Badge>
|
||||||
|
<dialog id="edit_alias_dialog" className="modal">
|
||||||
|
<div className="modal-box">
|
||||||
|
<h3 className="font-bold text-lg">{t("Edit Alias")}</h3>
|
||||||
|
<p className="py-4">
|
||||||
|
{alias.map((e) => {
|
||||||
|
return (
|
||||||
|
<Badge className={"m-1 badge-primary badge-soft"}>
|
||||||
|
<span className={"text-sm pt-0.5"}>{e}</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"inline-flex items-center justify-center cursor-pointer hover:bg-base-300 transition-colors rounded-full h-5 w-5"
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setAlias((prev) => prev.filter((x) => x !== e));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdClose />
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className={"flex"}>
|
||||||
|
<input
|
||||||
|
className={"input flex-1"}
|
||||||
|
value={content}
|
||||||
|
onInput={(e) => {
|
||||||
|
setContent(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className={"w-4"}></span>
|
||||||
|
<Button
|
||||||
|
className={"btn-circle"}
|
||||||
|
onClick={() => {
|
||||||
|
if (content.trim() === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAlias((prev) => [...prev, content.trim()]);
|
||||||
|
setContent("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdAdd size={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && <ErrorAlert className={"mt-2"} message={error} />}
|
||||||
|
<div className="modal-action">
|
||||||
|
<form method="dialog">
|
||||||
|
<Button className="btn btn-ghost">{t("Close")}</Button>
|
||||||
|
</form>
|
||||||
|
<Button
|
||||||
|
onClick={submit}
|
||||||
|
isLoading={isLoading}
|
||||||
|
className={"btn-primary"}
|
||||||
|
>
|
||||||
|
{t("Submit")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -180,12 +180,44 @@ func getOrCreateTags(c fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func editTagAlias(c fiber.Ctx) error {
|
||||||
|
uid, ok := c.Locals("uid").(uint)
|
||||||
|
if !ok {
|
||||||
|
return model.NewUnAuthorizedError("You must be logged in to edit tag aliases")
|
||||||
|
}
|
||||||
|
|
||||||
|
idStr := c.Params("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
return model.NewRequestError("Invalid tag ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Aliases []string `json:"aliases"`
|
||||||
|
}
|
||||||
|
if err := c.Bind().JSON(&req); err != nil {
|
||||||
|
return model.NewRequestError("Invalid request format")
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := service.EditTagAlias(uid, uint(id), req.Aliases)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusOK).JSON(model.Response[model.TagView]{
|
||||||
|
Success: true,
|
||||||
|
Data: *tag,
|
||||||
|
Message: "Tag aliases updated successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func AddTagRoutes(api fiber.Router) {
|
func AddTagRoutes(api fiber.Router) {
|
||||||
tag := api.Group("/tag")
|
tag := api.Group("/tag")
|
||||||
{
|
{
|
||||||
tag.Post("/", handleCreateTag)
|
tag.Post("/", handleCreateTag)
|
||||||
tag.Get("/search", handleSearchTag)
|
tag.Get("/search", handleSearchTag)
|
||||||
tag.Delete("/:id", handleDeleteTag)
|
tag.Delete("/:id", handleDeleteTag)
|
||||||
|
tag.Put("/:id/alias", editTagAlias)
|
||||||
tag.Put("/:id/info", handleSetTagInfo)
|
tag.Put("/:id/info", handleSetTagInfo)
|
||||||
tag.Get("/:name", handleGetTagByName)
|
tag.Get("/:name", handleGetTagByName)
|
||||||
tag.Get("/", getAllTags)
|
tag.Get("/", getAllTags)
|
||||||
|
@@ -3,12 +3,16 @@ package dao
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"nysoure/server/model"
|
"nysoure/server/model"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateTag(tag string) (model.Tag, error) {
|
func CreateTag(tag string) (model.Tag, error) {
|
||||||
// Create a new tag in the database
|
// Create a new tag in the database
|
||||||
|
if strings.Contains(tag, "%") {
|
||||||
|
return model.Tag{}, model.NewRequestError("Tag name cannot contain '%' character")
|
||||||
|
}
|
||||||
t := model.Tag{Name: tag}
|
t := model.Tag{Name: tag}
|
||||||
if err := db.Create(&t).Error; err != nil {
|
if err := db.Create(&t).Error; err != nil {
|
||||||
return model.Tag{}, err
|
return model.Tag{}, err
|
||||||
@@ -18,6 +22,9 @@ func CreateTag(tag string) (model.Tag, error) {
|
|||||||
|
|
||||||
func CreateTagWithType(tag string, tagType string) (model.Tag, error) {
|
func CreateTagWithType(tag string, tagType string) (model.Tag, error) {
|
||||||
// Create a new tag with a specific type in the database
|
// Create a new tag with a specific type in the database
|
||||||
|
if strings.Contains(tag, "%") {
|
||||||
|
return model.Tag{}, model.NewRequestError("Tag name cannot contain '%' character")
|
||||||
|
}
|
||||||
t := model.Tag{Name: tag, Type: tagType}
|
t := model.Tag{Name: tag, Type: tagType}
|
||||||
if err := db.Create(&t).Error; err != nil {
|
if err := db.Create(&t).Error; err != nil {
|
||||||
return model.Tag{}, err
|
return model.Tag{}, err
|
||||||
@@ -101,3 +108,35 @@ func ListTags() ([]model.Tag, error) {
|
|||||||
}
|
}
|
||||||
return tags, nil
|
return tags, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTagAlias sets a tag with the given ID having the given alias.
|
||||||
|
func SetTagAlias(tagID uint, alias string) error {
|
||||||
|
// Set a tag as an alias of another tag
|
||||||
|
var t model.Tag
|
||||||
|
if err := db.Where("name = ?", alias).First(&t).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
// create
|
||||||
|
newTag, err := CreateTag(alias)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t = newTag
|
||||||
|
} else {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t.ID == tagID {
|
||||||
|
return model.NewRequestError("Tag cannot be an alias of itself")
|
||||||
|
}
|
||||||
|
return db.Model(&t).Update("alias_of", tagID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveTagAliasOf sets a tag is an independent tag, removing its alias relationship.
|
||||||
|
func RemoveTagAliasOf(tagID uint) error {
|
||||||
|
// Remove the alias of a tag
|
||||||
|
return db.Model(&model.Tag{
|
||||||
|
Model: gorm.Model{
|
||||||
|
ID: tagID,
|
||||||
|
},
|
||||||
|
}).Update("alias_of", nil).Error
|
||||||
|
}
|
||||||
|
@@ -4,6 +4,8 @@ import (
|
|||||||
"github.com/gofiber/fiber/v3/log"
|
"github.com/gofiber/fiber/v3/log"
|
||||||
"nysoure/server/dao"
|
"nysoure/server/dao"
|
||||||
"nysoure/server/model"
|
"nysoure/server/model"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateTag(uid uint, name string) (*model.TagView, error) {
|
func CreateTag(uid uint, name string) (*model.TagView, error) {
|
||||||
@@ -161,3 +163,68 @@ func GetOrCreateTags(uid uint, names []string, tagType string) ([]model.TagView,
|
|||||||
}
|
}
|
||||||
return tags, updateCachedTagList()
|
return tags, updateCachedTagList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func EditTagAlias(uid uint, tagID uint, aliases []string) (*model.TagView, error) {
|
||||||
|
canUpload, err := checkUserCanUpload(uid)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error checking user permissions:", err)
|
||||||
|
return nil, model.NewInternalServerError("Error checking user permissions")
|
||||||
|
}
|
||||||
|
if !canUpload {
|
||||||
|
return nil, model.NewUnAuthorizedError("User cannot create tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := dao.GetTagByID(tagID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if tag.AliasOf != nil {
|
||||||
|
return nil, model.NewRequestError("Cannot edit aliases of a tag that is an alias of another tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
// trim params
|
||||||
|
for i, alias := range aliases {
|
||||||
|
aliases[i] = strings.TrimSpace(alias)
|
||||||
|
if aliases[i] == "" {
|
||||||
|
return nil, model.NewRequestError("Alias cannot be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// new aliases
|
||||||
|
for _, name := range aliases {
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exists := false
|
||||||
|
for _, alias := range tag.Aliases {
|
||||||
|
if alias.Name == name {
|
||||||
|
exists = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
err := dao.SetTagAlias(tagID, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove old aliases
|
||||||
|
for _, alias := range tag.Aliases {
|
||||||
|
if !slices.Contains(aliases, alias.Name) {
|
||||||
|
err := dao.RemoveTagAliasOf(alias.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := dao.GetTagByID(tagID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.ToView(), updateCachedTagList()
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user