aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2025-07-19 17:06:56 +0530
committerBobby <[email protected]>2025-07-19 17:06:56 +0530
commit3f73b3c66de04a55bc101ffb96070ae19e7bf27a (patch)
tree85ca777a49e3ec533b2fbc3c709ceb6e093c89c0
parentd31111cf0133b223a8e665e6798b8ae09aa5c8a9 (diff)
downloadimageboard-3f73b3c66de04a55bc101ffb96070ae19e7bf27a.tar.xz
imageboard-3f73b3c66de04a55bc101ffb96070ae19e7bf27a.zip
tag adding feature for images
-rw-r--r--controllers/posts.go21
-rw-r--r--controllers/tags.go279
-rw-r--r--database/database.go2
-rw-r--r--database/tags.go190
-rw-r--r--models/image.go16
-rw-r--r--models/tags.go67
-rw-r--r--router/routes.go7
-rw-r--r--static/css/main.css252
-rw-r--r--static/scripts/tagEditor.js446
-rw-r--r--templates/posts/edit.django185
10 files changed, 1376 insertions, 89 deletions
diff --git a/controllers/posts.go b/controllers/posts.go
index 57738b5..da2df95 100644
--- a/controllers/posts.go
+++ b/controllers/posts.go
@@ -4,7 +4,6 @@ import (
"errors"
"imageboard/config"
"imageboard/database"
- "imageboard/models"
"imageboard/utils/auth"
"imageboard/utils/format"
"imageboard/utils/handlers"
@@ -380,22 +379,9 @@ func PostsSinglePostEditPageController(ctx *fiber.Ctx) error {
return InternalServerErrorController(ctx, err)
}
- postTags := make([]map[string]models.Tag, 0, len(post.Tags))
- for _, tag := range post.Tags {
- switch tag.Type {
- case config.TagTypeGeneral:
- postTags = append(postTags, map[string]models.Tag{"general": tag})
- case config.TagTypeArtist:
- postTags = append(postTags, map[string]models.Tag{"artist": tag})
- case config.TagTypeCharacter:
- postTags = append(postTags, map[string]models.Tag{"character": tag})
- case config.TagTypeCopyright:
- postTags = append(postTags, map[string]models.Tag{"copyright": tag})
- case config.TagTypeMeta:
- postTags = append(postTags, map[string]models.Tag{"meta": tag})
- default:
- postTags = append(postTags, map[string]models.Tag{"general": tag})
- }
+ postTags, err := database.GetImageTags(post.ID)
+ if err != nil {
+ return InternalServerErrorController(ctx, err)
}
ctx.Locals("Title", config.PT_POST_EDIT+" #"+format.Int64ToString(int64(post.ID)))
@@ -511,6 +497,5 @@ func PostsSinglePostEditPostController(ctx *fiber.Ctx) error {
if nextURL == "" {
nextURL = "/posts/" + format.Int64ToString(int64(post.ID))
}
-
return ctx.Redirect(nextURL, fiber.StatusSeeOther)
}
diff --git a/controllers/tags.go b/controllers/tags.go
new file mode 100644
index 0000000..833c11d
--- /dev/null
+++ b/controllers/tags.go
@@ -0,0 +1,279 @@
+package controllers
+
+import (
+ "imageboard/config"
+ "imageboard/database"
+ "imageboard/models"
+ "imageboard/utils/auth"
+ "imageboard/utils/format"
+ "strconv"
+ "strings"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func TagsSearchJSONController(ctx *fiber.Ctx) error {
+ tagName := ctx.Query("name")
+ tagType := config.TagType(ctx.Query("type"))
+ limit := ctx.QueryInt("limit", 20)
+ offset := ctx.QueryInt("offset", 0)
+ if tagName == "" {
+ return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "Tag name is required",
+ })
+ }
+
+ tags, err := database.SearchTags(tagName, limit, offset, &tagType)
+ if err != nil {
+ return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+ "error": "Failed to fetch tags",
+ })
+ }
+
+ if len(tags) == 0 {
+ return ctx.Status(fiber.StatusNotFound).JSON(fiber.Map{
+ "error": "No tags found",
+ })
+ }
+
+ return ctx.JSON(tags)
+}
+
+func FindOrCreateTagJSONController(ctx *fiber.Ctx) error {
+ var request struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ }
+
+ if !auth.IsAuthenticated(ctx) {
+ return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
+ "error": "Unauthorized",
+ })
+ }
+
+ currentUser := auth.GetCurrentUser(ctx)
+ if !currentUser.CanCreateTags() {
+ return ctx.Status(fiber.StatusForbidden).JSON(fiber.Map{
+ "error": "You do not have permission to create tags",
+ })
+ }
+
+ if err := ctx.BodyParser(&request); err != nil {
+ return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "Invalid request body",
+ })
+ }
+
+ tag, err := database.FindOrCreateTag(request.Name, config.TagType(request.Type))
+ if err != nil {
+ return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+ "error": "Failed to find or create tag",
+ })
+ }
+
+ return ctx.JSON(tag)
+}
+
+func TagsSearchForImageJSONController(ctx *fiber.Ctx) error {
+ if !auth.IsAuthenticated(ctx) {
+ return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
+ "error": "Unauthorized",
+ })
+ }
+
+ query := ctx.Query("q")
+ imageID := ctx.Query("image_id")
+ tagType := ctx.Query("type")
+
+ if query == "" {
+ return ctx.JSON([]interface{}{})
+ }
+
+ var uintImageID uint
+ if imageID != "" {
+ if id, err := strconv.ParseUint(imageID, 10, 32); err == nil {
+ uintImageID = uint(id)
+ }
+ }
+
+ var tagTypeEnum *config.TagType
+ if tagType != "" {
+ t := config.TagType(tagType)
+ tagTypeEnum = &t
+ }
+
+ var tags []models.Tag
+ var err error
+
+ if uintImageID > 0 {
+ tags, err = database.SearchTagsExcluding(query, uintImageID, 10, tagTypeEnum)
+ } else {
+ tags, err = database.SearchTags(query, 10, 0, tagTypeEnum)
+ }
+
+ if err != nil {
+ return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+ "error": "Search failed",
+ })
+ }
+
+ currentUser := auth.GetCurrentUser(ctx)
+ canCreate := currentUser != nil && currentUser.CanCreateTags()
+
+ result := fiber.Map{
+ "tags": tags,
+ "can_create": canCreate,
+ "query": query,
+ }
+
+ return ctx.JSON(result)
+}
+
+func TagsAddToImageJSONController(ctx *fiber.Ctx) error {
+ if !auth.IsAuthenticated(ctx) {
+ return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
+ "error": "Unauthorized",
+ })
+ }
+
+ imageID := ctx.Query("image_id")
+ if imageID == "" {
+ return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "Image ID required",
+ })
+ }
+
+ uintImageID, err := format.StringToUint(imageID)
+ if err != nil {
+ return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "Invalid image ID",
+ })
+ }
+
+ var request struct {
+ TagName string `json:"tag_name"`
+ TagType string `json:"tag_type"`
+ }
+
+ if err := ctx.BodyParser(&request); err != nil {
+ return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "Invalid request body",
+ })
+ }
+
+ if request.TagName == "" {
+ return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "Tag name required",
+ })
+ }
+
+ if request.TagType == "" {
+ request.TagType = "general"
+ }
+
+ tagTypeEnum := config.TagType(request.TagType)
+ currentUser := auth.GetCurrentUser(ctx)
+
+ tag, err := database.FindOrCreateTag(request.TagName, tagTypeEnum)
+ if err != nil {
+ // Check if this is a cross-category error
+ if strings.Contains(err.Error(), "already exists as") || strings.Contains(err.Error(), "previously existed as") {
+ return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": err.Error(),
+ })
+ }
+ // For other errors, check permissions
+ if !currentUser.CanCreateTags() {
+ return ctx.Status(fiber.StatusForbidden).JSON(fiber.Map{
+ "error": "Cannot create new tags",
+ })
+ }
+ return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+ "error": "Failed to create tag",
+ })
+ }
+
+ var tagsToAdd []uint
+ if tag.ParentID != nil {
+ _, ancestors, err := database.GetTagWithAncestors(tag.ID)
+ if err == nil {
+ for _, ancestor := range ancestors {
+ tagsToAdd = append(tagsToAdd, ancestor.ID)
+ }
+ }
+ }
+ tagsToAdd = append(tagsToAdd, tag.ID)
+
+ for _, tagID := range tagsToAdd {
+ database.AddTagToImage(uintImageID, tagID)
+ }
+
+ return ctx.JSON(fiber.Map{
+ "success": true,
+ "tag": tag,
+ })
+}
+
+func TagsRemoveFromImageJSONController(ctx *fiber.Ctx) error {
+ if !auth.IsAuthenticated(ctx) {
+ return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
+ "error": "Unauthorized",
+ })
+ }
+
+ imageID := ctx.Query("image_id")
+ if imageID == "" {
+ return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "Image ID required",
+ })
+ }
+
+ uintImageID, err := format.StringToUint(imageID)
+ if err != nil {
+ return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "Invalid image ID",
+ })
+ }
+
+ var request struct {
+ TagID uint `json:"tag_id"`
+ }
+
+ if err := ctx.BodyParser(&request); err != nil {
+ return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "Invalid request body",
+ })
+ }
+
+ currentUser := auth.GetCurrentUser(ctx)
+ post, err := database.GetPostByID(uintImageID)
+ if err != nil {
+ return ctx.Status(fiber.StatusNotFound).JSON(fiber.Map{
+ "error": "Post not found",
+ })
+ }
+
+ if post.Uploader.Username != currentUser.Username && !currentUser.CanEditPosts() {
+ return ctx.Status(fiber.StatusForbidden).JSON(fiber.Map{
+ "error": "Cannot edit this post",
+ })
+ }
+
+ _, descendants, err := database.GetTagWithDescendants(request.TagID)
+ if err == nil {
+ for _, descendant := range descendants {
+ database.RemoveTagFromImage(uintImageID, descendant.ID)
+ }
+ }
+
+ err = database.RemoveTagFromImage(uintImageID, request.TagID)
+ if err != nil {
+ return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+ "error": "Failed to remove tag",
+ })
+ }
+
+ return ctx.JSON(fiber.Map{
+ "success": true,
+ })
+}
diff --git a/database/database.go b/database/database.go
index dedae59..a5696b0 100644
--- a/database/database.go
+++ b/database/database.go
@@ -63,6 +63,8 @@ func autoMigrate() error {
&models.Image{},
&models.ImageSize{},
&models.Tag{},
+ &models.TagWiki{},
+ &models.ImageTag{},
&models.Comment{},
&models.EmailToken{},
)
diff --git a/database/tags.go b/database/tags.go
index 199087a..0891dfc 100644
--- a/database/tags.go
+++ b/database/tags.go
@@ -1,7 +1,10 @@
package database
import (
+ "fmt"
+ "imageboard/config"
"imageboard/models"
+ "strings"
)
func GetTotalTagsCount() (int64, error) {
@@ -21,3 +24,190 @@ func GetRecentTags(limit int) ([]models.Tag, error) {
err := DB.Where("is_deleted = ?", false).Order("created_at DESC").Limit(limit).Find(&tags).Error
return tags, err
}
+
+func SearchTags(query string, limit int, offset int, tagType *config.TagType) ([]models.Tag, error) {
+ var tags []models.Tag
+ searchPattern := "%" + strings.TrimSpace(strings.ToLower(query)) + "%"
+
+ dbQuery := DB.Where("name LIKE ? AND is_deleted = ?", searchPattern, false)
+ if tagType != nil && strings.ToLower(string(*tagType)) != "" {
+ dbQuery = dbQuery.Where("type = ?", strings.ToLower(string(*tagType)))
+ }
+ dbQuery = dbQuery.Order("count DESC, name ASC").Limit(limit).Offset(offset)
+
+ err := dbQuery.Find(&tags).Error
+ return tags, err
+}
+
+func SearchTagsExcluding(query string, imageID uint, limit int, tagType *config.TagType) ([]models.Tag, error) {
+ var tags []models.Tag
+ searchPattern := "%" + strings.TrimSpace(strings.ToLower(query)) + "%"
+
+ dbQuery := DB.Where("name LIKE ? AND is_deleted = ? AND id NOT IN (?)",
+ searchPattern, false,
+ DB.Table("image_tags").Select("tag_id").Where("image_id = ?", imageID))
+
+ if tagType != nil && strings.ToLower(string(*tagType)) != "" {
+ dbQuery = dbQuery.Where("type = ?", strings.ToLower(string(*tagType)))
+ }
+
+ err := dbQuery.Order("count DESC, name ASC").Limit(limit).Find(&tags).Error
+
+ return tags, err
+}
+
+func FindOrCreateTag(name string, tagType config.TagType) (*models.Tag, error) {
+ name = strings.TrimSpace(strings.ToLower(name))
+
+ // First check for active tag with exact name and type match
+ var tag models.Tag
+ if err := DB.Where("name = ? AND type = ? AND is_deleted = ?", name, tagType, false).First(&tag).Error; err == nil {
+ return &tag, nil
+ }
+
+ // Check if a tag with the same name but different type exists
+ var existingTag models.Tag
+ if err := DB.Where("name = ? AND is_deleted = ?", name, false).First(&existingTag).Error; err == nil {
+ if existingTag.Type != tagType {
+ return nil, fmt.Errorf("tag '%s' already exists as %s type", name, existingTag.Type)
+ }
+ }
+
+ // Check for deleted tag with same name and type and restore it
+ if err := DB.Where("name = ? AND type = ? AND is_deleted = ?", name, tagType, true).First(&tag).Error; err == nil {
+ tag.IsDeleted = false
+ if err := DB.Save(&tag).Error; err != nil {
+ return nil, fmt.Errorf("failed to restore tag: %v", err)
+ }
+ return &tag, nil
+ }
+
+ // Check if a deleted tag with same name but different type exists
+ var deletedTag models.Tag
+ if err := DB.Where("name = ? AND is_deleted = ?", name, true).First(&deletedTag).Error; err == nil {
+ if deletedTag.Type != tagType {
+ return nil, fmt.Errorf("tag '%s' previously existed as %s type", name, deletedTag.Type)
+ }
+ }
+
+ // Create new tag
+ tag = models.Tag{
+ Name: name,
+ Type: tagType,
+ }
+
+ if err := DB.Create(&tag).Error; err != nil {
+ return nil, err
+ }
+
+ return &tag, nil
+}
+
+func AddTagToImage(imageID uint, tagID uint) error {
+ // First get the tag to validate it exists and is not deleted
+ var tag models.Tag
+ if err := DB.Where("id = ? AND is_deleted = ?", tagID, false).First(&tag).Error; err != nil {
+ return fmt.Errorf("tag not found or is deleted")
+ }
+
+ // Check if the association already exists
+ var count int64
+ err := DB.Table("image_tags").Where("image_id = ? AND tag_id = ?", imageID, tagID).Count(&count).Error
+ if err != nil {
+ return err
+ }
+
+ // If it doesn't exist, create it
+ if count == 0 {
+ err := DB.Exec("INSERT INTO image_tags (image_id, tag_id) VALUES (?, ?)", imageID, tagID).Error
+ if err != nil {
+ return err
+ }
+ // Increment tag count by 1
+ return DB.Model(&models.Tag{}).Where("id = ?", tagID).Update("count", DB.Raw("count + 1")).Error
+ }
+
+ return nil // Already exists
+}
+
+func RemoveTagFromImage(imageID uint, tagID uint) error {
+ err := DB.Exec("DELETE FROM image_tags WHERE image_id = ? AND tag_id = ?", imageID, tagID).Error
+ if err != nil {
+ return err
+ }
+ // Decrement tag count by 1
+ return DB.Model(&models.Tag{}).Where("id = ?", tagID).Update("count", DB.Raw("count - 1")).Error
+}
+
+func GetImageTags(imageID uint) (map[string][]models.Tag, error) {
+ var tags []models.Tag
+ err := DB.Joins("JOIN image_tags ON image_tags.tag_id = tags.id").
+ Where("image_tags.image_id = ? AND tags.is_deleted = ?", imageID, false).
+ Preload("Parent").Preload("Children").Find(&tags).Error
+
+ if err != nil {
+ return nil, err
+ }
+
+ result := map[string][]models.Tag{
+ "general": {},
+ "artist": {},
+ "character": {},
+ "copyright": {},
+ "meta": {},
+ }
+
+ for _, tag := range tags {
+ switch tag.Type {
+ case config.TagTypeGeneral:
+ result["general"] = append(result["general"], tag)
+ case config.TagTypeArtist:
+ result["artist"] = append(result["artist"], tag)
+ case config.TagTypeCharacter:
+ result["character"] = append(result["character"], tag)
+ case config.TagTypeCopyright:
+ result["copyright"] = append(result["copyright"], tag)
+ case config.TagTypeMeta:
+ result["meta"] = append(result["meta"], tag)
+ }
+ }
+
+ return result, nil
+}
+
+func GetTagWithAncestors(tagID uint) (*models.Tag, []models.Tag, error) {
+ var tag models.Tag
+ if err := DB.Preload("Parent").Preload("Children").First(&tag, tagID).Error; err != nil {
+ return nil, nil, err
+ }
+
+ var ancestors []models.Tag
+ current := &tag
+ for current.Parent != nil {
+ ancestors = append(ancestors, *current.Parent)
+ current = current.Parent
+ }
+
+ return &tag, ancestors, nil
+}
+
+func GetTagWithDescendants(tagID uint) (*models.Tag, []models.Tag, error) {
+ var tag models.Tag
+ if err := DB.Preload("Children").First(&tag, tagID).Error; err != nil {
+ return nil, nil, err
+ }
+
+ var descendants []models.Tag
+ var getChildren func(t *models.Tag)
+ getChildren = func(t *models.Tag) {
+ for _, child := range t.Children {
+ descendants = append(descendants, child)
+ childWithChildren := models.Tag{}
+ DB.Preload("Children").First(&childWithChildren, child.ID)
+ getChildren(&childWithChildren)
+ }
+ }
+
+ getChildren(&tag)
+ return &tag, descendants, nil
+}
diff --git a/models/image.go b/models/image.go
index 35b407e..6dc8e32 100644
--- a/models/image.go
+++ b/models/image.go
@@ -75,7 +75,7 @@ type Image struct {
FavouriteCount int64 `gorm:"not null;default:0" json:"favorite_count"`
CommentCount int64 `gorm:"not null;default:0" json:"comment_count"`
Sizes []ImageSize `gorm:"foreignKey:ImageID" json:"sizes,omitempty"`
- Tags []Tag `gorm:"many2many:image_tags;joinForeignKey:image_id;joinReferences:tag_id" json:"tags,omitempty"`
+ Tags []Tag `gorm:"many2many:image_tags;joinForeignKey:image_id;joinReferences:tag_id;constraint:OnDelete:CASCADE" json:"tags,omitempty"`
FavoritedBy []User `gorm:"many2many:user_favorites" json:"favorited_by,omitempty"`
Comments []Comment `gorm:"foreignKey:ImageID" json:"comments,omitempty"`
}
@@ -326,3 +326,17 @@ func (i *Image) IsUserFavourited(tx *gorm.DB, user *User) bool {
tx.Table("user_favorites").Where("user_id = ? AND image_id = ?", user.ID, i.ID).Count(&count)
return count > 0
}
+
+// ImageTag represents the many-to-many relationship between images and tags
+// with a unique constraint to prevent duplicate associations
+type ImageTag struct {
+ ImageID uint `gorm:"not null;index;uniqueIndex:idx_image_tag_unique" json:"image_id"`
+ TagID uint `gorm:"not null;index;uniqueIndex:idx_image_tag_unique" json:"tag_id"`
+ Image Image `gorm:"foreignKey:ImageID;constraint:OnDelete:CASCADE" json:"-"`
+ Tag Tag `gorm:"foreignKey:TagID;constraint:OnDelete:CASCADE" json:"-"`
+}
+
+// TableName specifies the table name for the ImageTag model
+func (ImageTag) TableName() string {
+ return "image_tags"
+}
diff --git a/models/tags.go b/models/tags.go
index c3c9063..986338e 100644
--- a/models/tags.go
+++ b/models/tags.go
@@ -19,7 +19,18 @@ type Tag struct {
ParentID *uint `gorm:"index" json:"-"`
Parent *Tag `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
Children []Tag `gorm:"foreignKey:ParentID" json:"children,omitempty"`
- Images []Image `gorm:"many2many:image_tags;joinForeignKey:tag_id;joinReferences:image_id" json:"images,omitempty"`
+ Images []Image `gorm:"many2many:image_tags;joinForeignKey:tag_id;joinReferences:image_id;constraint:OnDelete:CASCADE" json:"images,omitempty"`
+ Wiki *TagWiki `gorm:"foreignKey:TagID" json:"wiki,omitempty"`
+}
+
+type TagWiki struct {
+ gorm.Model
+ TagID uint `gorm:"not null;uniqueIndex" json:"-"`
+ Tag Tag `gorm:"foreignKey:TagID" json:"tag,omitempty"`
+ Content string `gorm:"type:text" json:"content"`
+ EditorID uint `gorm:"not null" json:"-"`
+ Editor User `gorm:"foreignKey:EditorID" json:"editor,omitempty"`
+ IsProtected bool `gorm:"not null;default:false" json:"is_protected"`
}
func (t *Tag) BeforeCreate(tx *gorm.DB) error {
@@ -59,60 +70,6 @@ func (t *Tag) GetFullPath() string {
return t.Parent.GetFullPath() + ":" + t.Name
}
-func SearchTags(tx *gorm.DB, query string, limit int) ([]Tag, error) {
- var tags []Tag
- searchPattern := "%" + strings.TrimSpace(strings.ToLower(query)) + "%"
-
- err := tx.Where("name LIKE ? AND is_deleted = ?", searchPattern, false).
- Order("count DESC, name ASC").Limit(limit).Find(&tags).Error
-
- return tags, err
-}
-
-func SearchTagsExcluding(tx *gorm.DB, query string, imageID uint, limit int) ([]Tag, error) {
- var tags []Tag
- searchPattern := "%" + strings.TrimSpace(strings.ToLower(query)) + "%"
-
- err := tx.Where("name LIKE ? AND is_deleted = ? AND id NOT IN (?)",
- searchPattern, false,
- tx.Table("image_tags").Select("tag_id").Where("image_id = ?", imageID)).
- Order("count DESC, name ASC").Limit(limit).Find(&tags).Error
-
- return tags, err
-}
-
-func FindOrCreateTag(tx *gorm.DB, name string, tagType config.TagType) (*Tag, error) {
- name = strings.TrimSpace(strings.ToLower(name))
-
- // First check for active tag
- var tag Tag
- if err := tx.Where("name = ? AND is_deleted = ?", name, false).First(&tag).Error; err == nil {
- return &tag, nil
- }
-
- // Check for deleted tag and restore it
- if err := tx.Where("name = ? AND is_deleted = ?", name, true).First(&tag).Error; err == nil {
- tag.IsDeleted = false
- tag.Type = tagType // Update type in case it changed
- if err := tx.Save(&tag).Error; err != nil {
- return nil, fmt.Errorf("failed to restore tag: %v", err)
- }
- return &tag, nil
- }
-
- // Create new tag
- tag = Tag{
- Name: name,
- Type: tagType,
- }
-
- if err := tx.Create(&tag).Error; err != nil {
- return nil, err
- }
-
- return &tag, nil
-}
-
func (t *Tag) DeleteTag(tx *gorm.DB) error {
if t.IsDeleted {
return fmt.Errorf("tag is already deleted")
diff --git a/router/routes.go b/router/routes.go
index fd1f898..f94e060 100644
--- a/router/routes.go
+++ b/router/routes.go
@@ -22,6 +22,13 @@ func Initialize(router *fiber.App) {
posts.Get("/:id/edit", controllers.PostsSinglePostEditPageController)
posts.Post("/:id/edit", controllers.PostsSinglePostEditPostController)
+ tags := router.Group("/tags")
+ tags.Get("/search.json", controllers.TagsSearchJSONController)
+ tags.Post("/create.json", controllers.FindOrCreateTagJSONController)
+ tags.Get("/search_for_image.json", controllers.TagsSearchForImageJSONController)
+ tags.Post("/add_to_image.json", controllers.TagsAddToImageJSONController)
+ tags.Post("/remove_from_image.json", controllers.TagsRemoveFromImageJSONController)
+
login := router.Group("/login")
login.Get("/", controllers.LoginPageController)
login.Post("/", controllers.LoginPostController)
diff --git a/static/css/main.css b/static/css/main.css
index 640303c..a7b9eb9 100644
--- a/static/css/main.css
+++ b/static/css/main.css
@@ -1008,4 +1008,256 @@ footer::before {
.edit-sidebar>.post-detail-item>.post-detail-value {
word-break: break-all;
+}
+
+/* Tag Editor Styles */
+.tag-editor {
+ margin-top: 24px;
+ background-color: #0d0020;
+ border: 1px solid #4d4d80;
+ padding: 16px;
+}
+
+.tag-editor-title {
+ color: #ff99cc;
+ margin: 0 0 20px 0;
+ text-align: left;
+}
+
+.tag-category {
+ margin-bottom: 20px;
+ background: rgba(13, 0, 26, 0.8);
+ border: 1px solid #4d4d80;
+ padding: 12px;
+}
+
+.tag-category:hover {
+ border-color: #8080cc;
+}
+
+.tag-category-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid #6666cc;
+}
+
+.tag-category-title {
+ margin: 0;
+ color: #ffccff;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.tag-type-icon {
+ font-weight: bold;
+}
+
+.tag-count {
+ color: #cccccc;
+ font-weight: normal;
+}
+
+.tag-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 12px;
+ min-height: 32px;
+ padding: 8px;
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px dashed #333366;
+}
+
+.tag-item {
+ display: flex;
+ align-items: center;
+ background: #1a0033;
+ border: 1px solid #6666cc;
+ padding: 4px 8px;
+ gap: 4px;
+}
+
+.tag-item:hover {
+ background: #330066;
+ border-color: #9999ff;
+}
+
+.tag-link {
+ text-decoration: none;
+ font-weight: bold;
+}
+
+.tag-link:hover {
+ text-decoration: underline;
+}
+
+.tag-parent-indicator,
+.tag-children-indicator {
+ opacity: 0.7;
+ cursor: help;
+}
+
+.tag-parent-indicator {
+ color: #99ffcc;
+}
+
+.tag-children-indicator {
+ color: #ffcc99;
+}
+
+.tag-remove-btn {
+ background: none;
+ border: none;
+ color: #ff6666;
+ cursor: pointer;
+ font-weight: bold;
+ padding: 0;
+ width: 16px;
+ height: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.tag-remove-btn:hover {
+ background: #ff6666;
+ color: white;
+}
+
+.no-tags {
+ color: #666699;
+ font-style: italic;
+ font-size: 14px;
+ padding: 8px;
+ text-align: center;
+ width: 100%;
+}
+
+.tag-input-container {
+ position: relative;
+}
+
+.tag-input-wrapper {
+ position: relative;
+}
+
+.tag-input {
+ width: 100%;
+ background: rgba(0, 0, 0, 0.5);
+ border: 1px solid #6666cc;
+ border-radius: 4px;
+ padding: 8px 12px;
+ color: #ccccff;
+ font-size: 14px;
+ transition: all 0.3s ease;
+}
+
+.tag-input:focus {
+ border-color: #9999ff;
+ background: rgba(0, 0, 0, 0.7);
+ box-shadow: 0 0 10px rgba(153, 153, 255, 0.3);
+ outline: none;
+}
+
+.tag-input::placeholder {
+ color: #666699;
+ font-style: italic;
+}
+
+.tag-suggestions {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ background: linear-gradient(135deg, #0a0015, #1a0033);
+ border: 1px solid #6666cc;
+ border-top: none;
+ border-radius: 0 0 4px 4px;
+ max-height: 200px;
+ overflow-y: auto;
+ z-index: 1000;
+ display: none;
+}
+
+.tag-suggestions.show {
+ display: block;
+}
+
+.tag-suggestion {
+ padding: 8px 12px;
+ cursor: pointer;
+ border-bottom: 1px solid #333366;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ transition: all 0.2s ease;
+}
+
+.tag-suggestion:hover,
+.tag-suggestion.selected {
+ background: rgba(102, 102, 204, 0.3);
+}
+
+.tag-suggestion:last-child {
+ border-bottom: none;
+}
+
+.tag-suggestion-name {
+ font-weight: bold;
+}
+
+.tag-suggestion-count {
+ color: #666699;
+ font-size: 12px;
+}
+
+.tag-suggestion-create {
+ color: #99ffcc;
+ font-style: italic;
+}
+
+.tag-suggestion-create .tag-suggestion-name {
+ color: #99ffcc;
+}
+
+/* Tag type specific colors */
+.tag-category[data-type="general"] .tag-category-title .tag-type-icon {
+ color: #4ECDC4;
+}
+
+.tag-category[data-type="artist"] .tag-category-title .tag-type-icon {
+ color: #FF6B9D;
+}
+
+.tag-category[data-type="character"] .tag-category-title .tag-type-icon {
+ color: #FFB347;
+}
+
+.tag-category[data-type="copyright"] .tag-category-title .tag-type-icon {
+ color: #A8E6CF;
+}
+
+.tag-category[data-type="meta"] .tag-category-title .tag-type-icon {
+ color: #DDA0DD;
+}
+
+/* Loading animation */
+.tag-input.loading {
+ background-image: linear-gradient(90deg, transparent, rgba(153, 153, 255, 0.2), transparent);
+ background-size: 200% 100%;
+ animation: loading-shimmer 1.5s infinite;
+}
+
+@keyframes loading-shimmer {
+ 0% {
+ background-position: -200% 0;
+ }
+
+ 100% {
+ background-position: 200% 0;
+ }
} \ No newline at end of file
diff --git a/static/scripts/tagEditor.js b/static/scripts/tagEditor.js
new file mode 100644
index 0000000..95ba3ec
--- /dev/null
+++ b/static/scripts/tagEditor.js
@@ -0,0 +1,446 @@
+class TagEditor {
+ constructor() {
+ this.debounceTimer = null;
+ this.selectedSuggestionIndex = -1;
+ this.currentInput = null;
+ this.init();
+ }
+
+ init() {
+ document.addEventListener('DOMContentLoaded', () => {
+ this.bindEvents();
+ });
+ }
+
+ bindEvents() {
+ const tagInputs = document.querySelectorAll('.tag-input');
+ const tagRemoveBtns = document.querySelectorAll('.tag-remove-btn');
+
+ tagInputs.forEach(input => {
+ input.addEventListener('input', (e) => this.handleInput(e));
+ input.addEventListener('keydown', (e) => this.handleKeydown(e));
+ input.addEventListener('blur', (e) => this.handleBlur(e));
+ input.addEventListener('focus', (e) => this.handleFocus(e));
+ });
+
+ tagRemoveBtns.forEach(btn => {
+ btn.addEventListener('click', (e) => this.removeTag(e));
+ });
+
+ // Close suggestions when clicking outside
+ document.addEventListener('click', (e) => {
+ if (!e.target.closest('.tag-input-wrapper')) {
+ this.hideSuggestions();
+ }
+ });
+ }
+
+ handleInput(e) {
+ const input = e.target;
+ const query = input.value.trim();
+ const tagType = input.dataset.type;
+
+ clearTimeout(this.debounceTimer);
+
+ if (query.length < 2) {
+ this.hideSuggestions();
+ return;
+ }
+
+ input.classList.add('loading');
+
+ this.debounceTimer = setTimeout(() => {
+ this.searchTags(query, tagType, input);
+ }, 300);
+ }
+
+ handleKeydown(e) {
+ const suggestionsContainer = this.getSuggestionsContainer(e.target);
+ const suggestions = suggestionsContainer.querySelectorAll('.tag-suggestion');
+
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault();
+ this.selectedSuggestionIndex = Math.min(this.selectedSuggestionIndex + 1, suggestions.length - 1);
+ this.updateSuggestionSelection(suggestions);
+ break;
+
+ case 'ArrowUp':
+ e.preventDefault();
+ this.selectedSuggestionIndex = Math.max(this.selectedSuggestionIndex - 1, -1);
+ this.updateSuggestionSelection(suggestions);
+ break;
+
+ case 'Enter':
+ e.preventDefault();
+ if (this.selectedSuggestionIndex >= 0 && suggestions[this.selectedSuggestionIndex]) {
+ this.selectSuggestion(suggestions[this.selectedSuggestionIndex]);
+ } else {
+ this.addTagFromInput(e.target);
+ }
+ break;
+
+ case 'Escape':
+ this.hideSuggestions();
+ e.target.blur();
+ break;
+ }
+ }
+
+ handleBlur(e) {
+ // Delay hiding suggestions to allow for clicks
+ setTimeout(() => {
+ this.hideSuggestions();
+ }, 200);
+ }
+
+ handleFocus(e) {
+ this.currentInput = e.target;
+ const query = e.target.value.trim();
+ if (query.length >= 2) {
+ this.searchTags(query, e.target.dataset.type, e.target);
+ }
+ }
+
+ async searchTags(query, tagType, input) {
+ try {
+ const postId = this.getPostIdFromUrl();
+ const response = await fetch(`/tags/search_for_image.json?q=${encodeURIComponent(query)}&type=${tagType}&image_id=${postId}`);
+ const data = await response.json();
+
+ input.classList.remove('loading');
+ this.showSuggestions(data, input, query);
+ } catch (error) {
+ console.error('Search failed:', error);
+ input.classList.remove('loading');
+ this.hideSuggestions();
+ }
+ }
+
+ showSuggestions(data, input, query) {
+ const suggestionsContainer = this.getSuggestionsContainer(input);
+ const { tags, can_create } = data;
+ const expectedTagType = input.dataset.type;
+
+ let html = '';
+
+ // Existing tags - only show tags that match the expected type
+ tags.forEach(tag => {
+ if (tag.type === expectedTagType) {
+ html += `
+ <div class="tag-suggestion" data-tag-id="${tag.ID}" data-tag-name="${tag.name}" data-tag-type="${tag.type}">
+ <span class="tag-suggestion-name" style="color: ${this.getTagTypeColor(tag.type)};">${tag.name}</span>
+ <span class="tag-suggestion-count">(${tag.count})</span>
+ </div>
+ `;
+ }
+ });
+
+ // Create new tag option - only show if no exact match found and user can create tags
+ const exactMatch = tags.some(tag => tag.name.toLowerCase() === query.toLowerCase() && tag.type === expectedTagType);
+ const hasMatchingTypeTags = tags.some(tag => tag.type === expectedTagType);
+
+ if (can_create && !exactMatch && !hasMatchingTypeTags) {
+ html += `
+ <div class="tag-suggestion tag-suggestion-create" data-create="true" data-tag-name="${query}" data-tag-type="${expectedTagType}">
+ <span class="tag-suggestion-name">Create "${query}" as ${expectedTagType}</span>
+ <span class="tag-suggestion-count">New tag</span>
+ </div>
+ `;
+ }
+
+ suggestionsContainer.innerHTML = html;
+
+ if (html) {
+ suggestionsContainer.classList.add('show');
+ this.bindSuggestionEvents(suggestionsContainer);
+ this.selectedSuggestionIndex = -1;
+ } else {
+ suggestionsContainer.classList.remove('show');
+ }
+ }
+
+ bindSuggestionEvents(container) {
+ const suggestions = container.querySelectorAll('.tag-suggestion');
+ suggestions.forEach((suggestion, index) => {
+ suggestion.addEventListener('click', () => this.selectSuggestion(suggestion));
+ suggestion.addEventListener('mouseenter', () => {
+ this.selectedSuggestionIndex = index;
+ this.updateSuggestionSelection(suggestions);
+ });
+ });
+ }
+
+ updateSuggestionSelection(suggestions) {
+ suggestions.forEach((suggestion, index) => {
+ if (index === this.selectedSuggestionIndex) {
+ suggestion.classList.add('selected');
+ } else {
+ suggestion.classList.remove('selected');
+ }
+ });
+ }
+
+ async selectSuggestion(suggestion) {
+ const tagName = suggestion.dataset.tagName;
+ const tagType = suggestion.dataset.tagType;
+ const isCreate = suggestion.dataset.create === 'true';
+
+ try {
+ await this.addTag(tagName, tagType);
+ this.hideSuggestions();
+
+ if (this.currentInput) {
+ this.currentInput.value = '';
+ }
+ } catch (error) {
+ console.error('Failed to add tag:', error);
+ let errorMessage = error.message;
+
+ // Handle specific cross-category errors
+ if (errorMessage.includes('already exists as') || errorMessage.includes('previously existed as')) {
+ errorMessage = `Tag "${tagName}" ${errorMessage}`;
+ }
+
+ this.showError(errorMessage);
+ }
+ }
+
+ async addTagFromInput(input) {
+ const tagName = input.value.trim();
+ const tagType = input.dataset.type;
+
+ if (!tagName) return;
+
+ try {
+ await this.addTag(tagName, tagType);
+ input.value = '';
+ this.hideSuggestions();
+ } catch (error) {
+ console.error('Failed to add tag:', error);
+ let errorMessage = error.message;
+
+ // Handle specific cross-category errors
+ if (errorMessage.includes('already exists as') || errorMessage.includes('previously existed as')) {
+ errorMessage = `Tag "${tagName}" ${errorMessage}`;
+ }
+
+ this.showError(errorMessage);
+ }
+ }
+
+ async addTag(tagName, tagType) {
+ // Validate that we're adding to the correct category
+ if (!tagType || !['general', 'artist', 'character', 'copyright', 'meta'].includes(tagType)) {
+ throw new Error('Invalid tag type');
+ }
+
+ const postId = this.getPostIdFromUrl();
+ const response = await fetch(`/tags/add_to_image.json?image_id=${postId}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ tag_name: tagName,
+ tag_type: tagType
+ })
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Failed to add tag');
+ }
+
+ const result = await response.json();
+ if (result.success) {
+ // Validate that the returned tag matches the requested type
+ if (result.tag.type !== tagType) {
+ throw new Error(`Tag type mismatch: expected ${tagType}, got ${result.tag.type}`);
+ }
+ this.addTagToUI(result.tag, tagType);
+ }
+ } async removeTag(e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const tagId = e.target.dataset.tagId;
+ const tagItem = e.target.closest('.tag-item');
+
+ try {
+ const postId = this.getPostIdFromUrl();
+
+ const response = await fetch(`/tags/remove_from_image.json?image_id=${postId}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ tag_id: parseInt(tagId)
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to remove tag');
+ }
+
+ const result = await response.json();
+ if (result.success) {
+ const category = tagItem.closest('.tag-category');
+ const tagList = category.querySelector('.tag-list');
+
+ tagItem.style.animation = 'fadeOut 0.3s ease';
+ setTimeout(() => {
+ tagItem.remove();
+ this.updateTagCount(category);
+
+ // Check if no tags remain and add "No tags" message
+ const remainingTags = tagList.querySelectorAll('.tag-item');
+ if (remainingTags.length === 0) {
+ const tagType = category.dataset.type;
+ const noTagsDiv = document.createElement('div');
+ noTagsDiv.className = 'no-tags';
+ noTagsDiv.textContent = `No ${tagType} tags`;
+ tagList.appendChild(noTagsDiv);
+ }
+ }, 300);
+ }
+ } catch (error) {
+ console.error('Failed to remove tag:', error);
+ this.showError('Failed to remove tag');
+ }
+ } addTagToUI(tag, expectedTagType) {
+ // Validate that the tag type matches what we expect
+ if (tag.type !== expectedTagType) {
+ console.error(`Tag type mismatch: expected ${expectedTagType}, got ${tag.type}`);
+ this.showError(`Cannot add ${tag.type} tag "${tag.name}" to ${expectedTagType} section`);
+ return;
+ }
+
+ const tagList = document.getElementById(`tag-list-${expectedTagType}`);
+
+ // Check if tag already exists in this category
+ const existingTag = tagList.querySelector(`[data-tag-id="${tag.ID}"]`);
+ if (existingTag) {
+ return; // Tag already exists, don't add duplicate
+ }
+
+ // Also check if tag exists in any other category (cross-category validation)
+ const allTagLists = document.querySelectorAll('.tag-list');
+ for (let otherList of allTagLists) {
+ if (otherList.id !== `tag-list-${expectedTagType}`) {
+ const existingInOther = otherList.querySelector(`[data-tag-id="${tag.ID}"]`);
+ if (existingInOther) {
+ console.warn(`Tag "${tag.name}" already exists in another category`);
+ return;
+ }
+ }
+ }
+
+ const noTagsElement = tagList.querySelector('.no-tags');
+
+ if (noTagsElement) {
+ noTagsElement.remove();
+ }
+
+ const tagItem = document.createElement('div');
+ tagItem.className = 'tag-item';
+ tagItem.dataset.tagId = tag.ID;
+ tagItem.style.animation = 'fadeIn 0.3s ease';
+
+ tagItem.innerHTML = `
+ <a href="/tags/${tag.name}" class="tag-link" style="color: ${this.getTagTypeColor(tag.type)};">
+ ${tag.name}
+ </a>
+ ${tag.parent ? `<span class="tag-parent-indicator" title="Child of ${tag.parent.name}">⬆</span>` : ''}
+ ${tag.children && tag.children.length > 0 ? `<span class="tag-children-indicator" title="Has ${tag.children.length} children">⬇</span>` : ''}
+ <button type="button" class="tag-remove-btn" data-tag-id="${tag.ID}" title="Remove tag">×</button>
+ `;
+
+ tagList.appendChild(tagItem);
+
+ // Bind remove event
+ tagItem.querySelector('.tag-remove-btn').addEventListener('click', (e) => this.removeTag(e));
+
+ // Update count
+ this.updateTagCount(tagList.closest('.tag-category'));
+ }
+
+ updateTagCount(category) {
+ const tagList = category.querySelector('.tag-list');
+ const countElement = category.querySelector('.tag-count');
+ const tagItems = tagList.querySelectorAll('.tag-item');
+
+ countElement.textContent = `(${tagItems.length})`;
+ }
+
+ getSuggestionsContainer(input) {
+ return input.parentElement.querySelector('.tag-suggestions');
+ }
+
+ hideSuggestions() {
+ const allSuggestions = document.querySelectorAll('.tag-suggestions');
+ allSuggestions.forEach(container => {
+ container.classList.remove('show');
+ });
+ this.selectedSuggestionIndex = -1;
+ }
+
+ getPostIdFromUrl() {
+ const match = window.location.pathname.match(/\/posts\/(\d+)/);
+ return match ? match[1] : null;
+ }
+
+ getTagTypeColor(tagType) {
+ const colors = {
+ general: '#4ECDC4',
+ artist: '#FF6B9D',
+ character: '#FFB347',
+ copyright: '#A8E6CF',
+ meta: '#DDA0DD'
+ };
+ return colors[tagType] || '#E6E6FA';
+ }
+
+ showError(message) {
+ // Find the tag editor container and add error message
+ const tagEditor = document.querySelector('.tag-editor');
+
+ // Remove any existing error
+ const existingError = tagEditor.querySelector('.error');
+ if (existingError) {
+ existingError.remove();
+ }
+
+ // Create standard error div
+ const errorDiv = document.createElement('div');
+ errorDiv.className = 'error';
+ errorDiv.textContent = message;
+
+ // Insert at the top of tag editor
+ tagEditor.insertBefore(errorDiv, tagEditor.firstChild);
+
+ // Remove after 3 seconds
+ setTimeout(() => {
+ errorDiv.remove();
+ }, 3000);
+ }
+}
+
+// CSS animations for tag items
+const style = document.createElement('style');
+style.textContent = `
+ @keyframes fadeIn {
+ from { opacity: 0; transform: translateY(-10px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+
+ @keyframes fadeOut {
+ from { opacity: 1; transform: translateY(0); }
+ to { opacity: 0; transform: translateY(-10px); }
+ }
+`;
+document.head.appendChild(style);
+
+// Initialize the tag editor
+new TagEditor();
diff --git a/templates/posts/edit.django b/templates/posts/edit.django
index 93d582d..2e5b280 100644
--- a/templates/posts/edit.django
+++ b/templates/posts/edit.django
@@ -140,22 +140,174 @@
<input type="hidden" name="next" value="{{ Request.Path }}" />
<input type="submit" value="Save Changes" style="margin-top: 8px;" />
</form>
- <h1>Tags</h1>
- <div class="tag-list">
- <div class="post-detail-item">
- <span class="post-detail-label">General Tags:</span>
- <span class="post-detail-value">
- {% if PostTags.general|length > 0 %}
- {% for tag in PostTags.general %}
- <a href="/tags/{{ tag.Name }}" style="color: {{ tag.Type.Color }};">{{ tag.Name }}</a>
- {% endfor %}
- {% else %}
- <span class="no-tags">No general tags</span>
- {% endif %}
- </span>
+
+ <div class="tag-editor">
+ <h1 class="tag-editor-title">✦ Tags ✦</h1>
+
+ <div class="tag-category" data-type="general">
+ <div class="tag-category-header">
+ <h3 class="tag-category-title">
+ <span class="tag-type-icon">General</span>
+ <span class="tag-count">({{ PostTags.general|length }})</span>
+ </h3>
+ </div>
+
+ <div class="tag-list" id="tag-list-general">
+ {% for tag in PostTags.general %}
+ <div class="tag-item" data-tag-id="{{ tag.ID }}">
+ <a href="/tags/{{ tag.Name }}" class="tag-link" style="color: {{ tag.Type.Color }};">{{ tag.Name }}</a>
+ {% if tag.Parent %}
+ <span class="tag-parent-indicator" title="Child of {{ tag.Parent.Name }}">⬆</span>
+ {% endif %}
+ {% if tag.Children %}
+ <span class="tag-children-indicator" title="Has {{ tag.Children|length }} children">⬇</span>
+ {% endif %}
+ <button type="button" class="tag-remove-btn" data-tag-id="{{ tag.ID }}" title="Remove tag">×</button>
+ </div>
+ {% empty %}
+ <div class="no-tags">No general tags</div>
+ {% endfor %}
+ </div>
+
+ <div class="tag-input-container">
+ <div class="tag-input-wrapper">
+ <input type="text" class="tag-input" data-type="general" placeholder="Add general tag..." autocomplete="off" />
+ <div class="tag-suggestions" id="suggestions-general"></div>
+ </div>
+ </div>
+ </div>
+
+ <div class="tag-category" data-type="artist">
+ <div class="tag-category-header">
+ <h3 class="tag-category-title">
+ <span class="tag-type-icon">Artist</span>
+ <span class="tag-count">({{ PostTags.artist|length }})</span>
+ </h3>
+ </div>
+
+ <div class="tag-list" id="tag-list-artist">
+ {% for tag in PostTags.artist %}
+ <div class="tag-item" data-tag-id="{{ tag.ID }}">
+ <a href="/tags/{{ tag.Name }}" class="tag-link" style="color: {{ tag.Type.Color }};">{{ tag.Name }}</a>
+ {% if tag.Parent %}
+ <span class="tag-parent-indicator" title="Child of {{ tag.Parent.Name }}">⬆</span>
+ {% endif %}
+ {% if tag.Children %}
+ <span class="tag-children-indicator" title="Has {{ tag.Children|length }} children">⬇</span>
+ {% endif %}
+ <button type="button" class="tag-remove-btn" data-tag-id="{{ tag.ID }}" title="Remove tag">×</button>
+ </div>
+ {% empty %}
+ <div class="no-tags">No artist tags</div>
+ {% endfor %}
+ </div>
+
+ <div class="tag-input-container">
+ <div class="tag-input-wrapper">
+ <input type="text" class="tag-input" data-type="artist" placeholder="Add artist tag..." autocomplete="off" />
+ <div class="tag-suggestions" id="suggestions-artist"></div>
+ </div>
+ </div>
+ </div>
+
+ <div class="tag-category" data-type="character">
+ <div class="tag-category-header">
+ <h3 class="tag-category-title">
+ <span class="tag-type-icon">Character</span>
+ <span class="tag-count">({{ PostTags.character|length }})</span>
+ </h3>
+ </div>
+
+ <div class="tag-list" id="tag-list-character">
+ {% for tag in PostTags.character %}
+ <div class="tag-item" data-tag-id="{{ tag.ID }}">
+ <a href="/tags/{{ tag.Name }}" class="tag-link" style="color: {{ tag.Type.Color }};">{{ tag.Name }}</a>
+ {% if tag.Parent %}
+ <span class="tag-parent-indicator" title="Child of {{ tag.Parent.Name }}">⬆</span>
+ {% endif %}
+ {% if tag.Children %}
+ <span class="tag-children-indicator" title="Has {{ tag.Children|length }} children">⬇</span>
+ {% endif %}
+ <button type="button" class="tag-remove-btn" data-tag-id="{{ tag.ID }}" title="Remove tag">×</button>
+ </div>
+ {% empty %}
+ <div class="no-tags">No character tags</div>
+ {% endfor %}
+ </div>
+
+ <div class="tag-input-container">
+ <div class="tag-input-wrapper">
+ <input type="text" class="tag-input" data-type="character" placeholder="Add character tag..." autocomplete="off" />
+ <div class="tag-suggestions" id="suggestions-character"></div>
+ </div>
+ </div>
+ </div>
+
+ <div class="tag-category" data-type="copyright">
+ <div class="tag-category-header">
+ <h3 class="tag-category-title">
+ <span class="tag-type-icon">Copyright</span>
+ <span class="tag-count">({{ PostTags.copyright|length }})</span>
+ </h3>
+ </div>
+
+ <div class="tag-list" id="tag-list-copyright">
+ {% for tag in PostTags.copyright %}
+ <div class="tag-item" data-tag-id="{{ tag.ID }}">
+ <a href="/tags/{{ tag.Name }}" class="tag-link" style="color: {{ tag.Type.Color }};">{{ tag.Name }}</a>
+ {% if tag.Parent %}
+ <span class="tag-parent-indicator" title="Child of {{ tag.Parent.Name }}">⬆</span>
+ {% endif %}
+ {% if tag.Children %}
+ <span class="tag-children-indicator" title="Has {{ tag.Children|length }} children">⬇</span>
+ {% endif %}
+ <button type="button" class="tag-remove-btn" data-tag-id="{{ tag.ID }}" title="Remove tag">×</button>
+ </div>
+ {% empty %}
+ <div class="no-tags">No copyright tags</div>
+ {% endfor %}
+ </div>
+
+ <div class="tag-input-container">
+ <div class="tag-input-wrapper">
+ <input type="text" class="tag-input" data-type="copyright" placeholder="Add copyright tag..." autocomplete="off" />
+ <div class="tag-suggestions" id="suggestions-copyright"></div>
+ </div>
+ </div>
+ </div>
+
+ <div class="tag-category" data-type="meta">
+ <div class="tag-category-header">
+ <h3 class="tag-category-title">
+ <span class="tag-type-icon">Meta</span>
+ <span class="tag-count">({{ PostTags.meta|length }})</span>
+ </h3>
+ </div>
+
+ <div class="tag-list" id="tag-list-meta">
+ {% for tag in PostTags.meta %}
+ <div class="tag-item" data-tag-id="{{ tag.ID }}">
+ <a href="/tags/{{ tag.Name }}" class="tag-link" style="color: {{ tag.Type.Color }};">{{ tag.Name }}</a>
+ {% if tag.Parent %}
+ <span class="tag-parent-indicator" title="Child of {{ tag.Parent.Name }}">⬆</span>
+ {% endif %}
+ {% if tag.Children %}
+ <span class="tag-children-indicator" title="Has {{ tag.Children|length }} children">⬇</span>
+ {% endif %}
+ <button type="button" class="tag-remove-btn" data-tag-id="{{ tag.ID }}" title="Remove tag">×</button>
+ </div>
+ {% empty %}
+ <div class="no-tags">No meta tags</div>
+ {% endfor %}
+ </div>
+
+ <div class="tag-input-container">
+ <div class="tag-input-wrapper">
+ <input type="text" class="tag-input" data-type="meta" placeholder="Add meta tag..." autocomplete="off" />
+ <div class="tag-suggestions" id="suggestions-meta"></div>
+ </div>
+ </div>
</div>
- <input type="text" id="general-tag-input" class="itext" placeholder="Add general tag" />
- <button type="button" id="add-general-tag" class="ib-button">Add</button>
</div>
</div>
<div class="edit-sidebar">
@@ -207,3 +359,6 @@
</div>
</div>
{% endblock %}
+{% block scripts %}
+ <script type="text/javascript" src="/static/scripts/tagEditor.js"></script>
+{% endblock %}