diff options
| author | Bobby <[email protected]> | 2025-07-19 17:06:56 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2025-07-19 17:06:56 +0530 |
| commit | 3f73b3c66de04a55bc101ffb96070ae19e7bf27a (patch) | |
| tree | 85ca777a49e3ec533b2fbc3c709ceb6e093c89c0 | |
| parent | d31111cf0133b223a8e665e6798b8ae09aa5c8a9 (diff) | |
| download | imageboard-3f73b3c66de04a55bc101ffb96070ae19e7bf27a.tar.xz imageboard-3f73b3c66de04a55bc101ffb96070ae19e7bf27a.zip | |
tag adding feature for images
| -rw-r--r-- | controllers/posts.go | 21 | ||||
| -rw-r--r-- | controllers/tags.go | 279 | ||||
| -rw-r--r-- | database/database.go | 2 | ||||
| -rw-r--r-- | database/tags.go | 190 | ||||
| -rw-r--r-- | models/image.go | 16 | ||||
| -rw-r--r-- | models/tags.go | 67 | ||||
| -rw-r--r-- | router/routes.go | 7 | ||||
| -rw-r--r-- | static/css/main.css | 252 | ||||
| -rw-r--r-- | static/scripts/tagEditor.js | 446 | ||||
| -rw-r--r-- | templates/posts/edit.django | 185 |
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 %} |
