feat: implement tag management and search functionality
Features: - Tag system with multi-language support (Chinese/English) - Auto-create tags when associating with articles - Tag filtering on homepage with visual indicators - Full-text search across title, summary, and content - Tag cloud sidebar showing article counts - Search page with results display Technical Implementation: - Database models: Tag, ArticleTag (many-to-many) - Tag count caching for performance - Automatic tag slug generation - Responsive design with mobile support - I18n support for all new UI elements Bug Fix: - Fixed SQL column ambiguity error in tag filtering - Added table prefix to ORDER BY clause in publishedArticleOrder Routes: - GET /search - Search results page - GET /?tag=<slug> - Filter articles by tag - GET /api/articles?tag=<slug> - API with tag filter support Templates: - Added tag input field to article create/edit form - Added search box to homepage header - Added tag cloud sidebar on homepage and search page - Created new search results page template Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
# Tag and Search Feature - Implementation Plan
|
||||
|
||||
## Overview
|
||||
Add a comprehensive tag management system with search functionality to allow users to categorize articles with tags and search articles by keywords.
|
||||
|
||||
## Requirements
|
||||
Based on user request:
|
||||
1. **Tag Management**: Add tag functionality to articles
|
||||
2. **Tag Association**: When creating/editing articles, users can associate tags
|
||||
3. **Auto Tag Creation**: If a tag doesn't exist, create it automatically
|
||||
4. **Tag Display**: Show all tags in homepage right sidebar
|
||||
5. **Tag Filtering**: Filter articles by clicking on tags
|
||||
6. **Search Functionality**: Add search box in header to search articles by keywords
|
||||
|
||||
## Current Architecture Patterns
|
||||
|
||||
### Database Models
|
||||
- Models are in `/models/` directory
|
||||
- Use GORM with auto-migration in `models/db.go`
|
||||
- Multi-language support via `*Zh` and `*En` fields pattern
|
||||
- Article model in `models/article.go`
|
||||
|
||||
### Handlers
|
||||
- Article handlers in `handlers/article.go` and `handlers/home.go`
|
||||
- Settings handlers in `handlers/settings.go`
|
||||
- Pattern: `{Feature}Page()` for GET, `{Feature}()` for POST
|
||||
- Uses `DefaultData(c)` helper for common template data
|
||||
|
||||
### Templates
|
||||
- Home page at `templates/pages/home.html`
|
||||
- Article creation/edit at `templates/admin/article_create.html`
|
||||
- Layout header at `templates/layouts/base.html`
|
||||
|
||||
### Routing
|
||||
- Main routes in `main.go`
|
||||
- Public routes for home, article detail, search
|
||||
- Admin routes under `/admin` group with auth middleware
|
||||
|
||||
### i18n
|
||||
- Translation keys in `i18n/i18n.go`
|
||||
- Both EN and ZH translations required
|
||||
|
||||
## Proposed Implementation
|
||||
|
||||
### 1. Database Models
|
||||
|
||||
#### `models/tag.go`
|
||||
```go
|
||||
type Tag struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
NameZh string `gorm:"size:50;uniqueIndex:idx_tag_name_zh"`
|
||||
NameEn string `gorm:"size:50;uniqueIndex:idx_tag_name_en"`
|
||||
Slug string `gorm:"size:100;uniqueIndex"`
|
||||
Count int `gorm:"default:0"` // Article count cache
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Helper method to get tag name by language
|
||||
func (t *Tag) Name(lang string) string {
|
||||
if lang == "zh" {
|
||||
return t.NameZh
|
||||
}
|
||||
return t.NameEn
|
||||
}
|
||||
```
|
||||
|
||||
#### `models/article_tag.go` (Many-to-Many Join Table)
|
||||
```go
|
||||
type ArticleTag struct {
|
||||
ArticleID uint `gorm:"primaryKey;index"`
|
||||
TagID uint `gorm:"primaryKey;index"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
#### Update `models/article.go`
|
||||
```go
|
||||
type Article struct {
|
||||
// ... existing fields ...
|
||||
Tags []Tag `gorm:"many2many:article_tags;"`
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Database Migration
|
||||
Update `models/db.go` `AutoMigrate()`:
|
||||
- Add `&Tag{}` and `&ArticleTag{}` to the migration list
|
||||
|
||||
### 3. Tag Management Functions
|
||||
|
||||
#### Add to `models/tag.go`:
|
||||
```go
|
||||
// FindOrCreateTag finds a tag by name or creates it if not exists
|
||||
func FindOrCreateTag(db *gorm.DB, nameZh, nameEn string) (*Tag, error)
|
||||
|
||||
// GetAllTags returns all tags ordered by count descending
|
||||
func GetAllTags(db *gorm.DB) ([]Tag, error)
|
||||
|
||||
// GetTagBySlug returns a tag by its slug
|
||||
func GetTagBySlug(db *gorm.DB, slug string) (*Tag, error)
|
||||
|
||||
// UpdateTagCount recalculates article count for a tag
|
||||
func UpdateTagCount(db *gorm.DB, tagID uint) error
|
||||
```
|
||||
|
||||
### 4. Article Handler Updates
|
||||
|
||||
#### Update `handlers/article.go`:
|
||||
|
||||
**Modify `articleForm` struct:**
|
||||
```go
|
||||
type articleForm struct {
|
||||
// ... existing fields ...
|
||||
Tags string // Comma-separated tag names
|
||||
}
|
||||
```
|
||||
|
||||
**Modify `parseArticleForm`:**
|
||||
```go
|
||||
func parseArticleForm(c *gin.Context) articleForm {
|
||||
return articleForm{
|
||||
// ... existing fields ...
|
||||
Tags: strings.TrimSpace(c.PostForm("tags")),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Modify `applyFormToData`:**
|
||||
```go
|
||||
func applyFormToData(data gin.H, f articleForm) {
|
||||
// ... existing assignments ...
|
||||
data["FormTags"] = f.Tags
|
||||
}
|
||||
```
|
||||
|
||||
**Add `parseTags` helper:**
|
||||
```go
|
||||
// parseTags splits comma-separated tag string and returns tag names
|
||||
func parseTags(tagStr string) []string {
|
||||
if tagStr == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(tagStr, ",")
|
||||
var tags []string
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
tags = append(tags, trimmed)
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
```
|
||||
|
||||
**Add `syncArticleTags` helper:**
|
||||
```go
|
||||
// syncArticleTags associates tags with an article (find or create tags)
|
||||
func syncArticleTags(db *gorm.DB, article *models.Article, tagNames []string) error
|
||||
```
|
||||
|
||||
**Modify `ArticleCreate` and `ArticleUpdate`:**
|
||||
- Parse tags from form
|
||||
- Call `syncArticleTags` after article is created/updated
|
||||
- Update tag counts
|
||||
|
||||
**Modify `ArticleEditPage`:**
|
||||
- Load article with tags preloaded
|
||||
- Format tags as comma-separated string for form
|
||||
|
||||
### 5. Search and Filter Handlers
|
||||
|
||||
#### Add to `handlers/home.go`:
|
||||
|
||||
```go
|
||||
// SearchArticles handles article search by keyword
|
||||
func SearchArticles(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
keyword := strings.TrimSpace(c.Query("q"))
|
||||
tagSlug := strings.TrimSpace(c.Query("tag"))
|
||||
|
||||
// Build query with filters
|
||||
// Return paginated results
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Modify `HomePage` and `HomeArticlesAPI`:**
|
||||
- Add tag filtering support via query parameter
|
||||
- Add search keyword filtering support
|
||||
|
||||
### 6. Template Updates
|
||||
|
||||
#### Update `templates/admin/article_create.html`:
|
||||
Add tag input field after content section:
|
||||
```html
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_tags"}}</label>
|
||||
<input type="text" name="tags" value="{{.FormTags}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "article_tags_hint"}}">
|
||||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_tags_hint"}}</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Update `templates/pages/home.html`:
|
||||
|
||||
**Add search box in header (top section):**
|
||||
```html
|
||||
<section class="max-w-5xl mx-auto px-4 py-6">
|
||||
<div class="flex justify-center">
|
||||
<form action="/search" method="get" class="w-full max-w-2xl">
|
||||
<div class="relative">
|
||||
<input type="text" name="q" placeholder="{{index .Tr "search_placeholder"}}"
|
||||
class="w-full px-4 py-3 pl-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||||
<svg class="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
**Add sidebar for tags:**
|
||||
```html
|
||||
<section class="max-w-7xl mx-auto px-4 py-8">
|
||||
<div class="flex gap-8">
|
||||
<!-- Main content (articles) -->
|
||||
<div class="flex-1">
|
||||
<!-- existing articles container -->
|
||||
</div>
|
||||
|
||||
<!-- Sidebar (tags) -->
|
||||
<aside class="w-64 flex-shrink-0 hidden lg:block">
|
||||
<div class="sticky top-6">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-4">{{index .Tr "tags_title"}}</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{{range .Tags}}
|
||||
<a href="/?tag={{.Slug}}"
|
||||
class="px-3 py-1 text-sm bg-gray-100 hover:bg-blue-100 text-gray-700 hover:text-blue-700 rounded-full transition-colors">
|
||||
{{if eq $.Lang "zh"}}{{.NameZh}}{{else}}{{.NameEn}}{{end}}
|
||||
<span class="text-xs text-gray-500">({{.Count}})</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
#### Create `templates/pages/search.html`:
|
||||
New template for search results page with similar layout to home.
|
||||
|
||||
### 7. Routing Updates
|
||||
|
||||
Update `main.go`:
|
||||
```go
|
||||
// Public routes
|
||||
router.GET("/", handlers.HomePage(db))
|
||||
router.GET("/search", handlers.SearchPage(db))
|
||||
router.GET("/api/articles", handlers.HomeArticlesAPI(db))
|
||||
```
|
||||
|
||||
### 8. i18n Translations
|
||||
|
||||
Add to `i18n/i18n.go` for both EN and ZH:
|
||||
```go
|
||||
// Tags
|
||||
"tags_title": "Tags"
|
||||
"article_tags": "Tags"
|
||||
"article_tags_hint": "Comma-separated tag names, e.g., golang, web, tutorial"
|
||||
"tag_filter": "Filter by tag"
|
||||
"tag_all": "All"
|
||||
|
||||
// Search
|
||||
"search_placeholder": "Search articles..."
|
||||
"search_title": "Search Results"
|
||||
"search_results_for": "Search results for"
|
||||
"search_no_results": "No articles found matching your search."
|
||||
"search_keyword": "Keyword"
|
||||
```
|
||||
|
||||
### 9. Homepage Handler Updates
|
||||
|
||||
Update `handlers/home.go`:
|
||||
|
||||
**Modify `HomePage`:**
|
||||
```go
|
||||
func HomePage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
data := DefaultData(c)
|
||||
|
||||
// Get tag filter if present
|
||||
tagSlug := c.Query("tag")
|
||||
|
||||
// Build query
|
||||
query := db.Where("status = ?", models.ArticlePublished)
|
||||
|
||||
if tagSlug != "" {
|
||||
// Join with article_tags to filter by tag
|
||||
query = query.Joins("JOIN article_tags ON article_tags.article_id = articles.id").
|
||||
Joins("JOIN tags ON tags.id = article_tags.tag_id").
|
||||
Where("tags.slug = ?", tagSlug)
|
||||
}
|
||||
|
||||
var articles []models.Article
|
||||
query.Preload("Tags").
|
||||
Order(publishedArticleOrder).
|
||||
Limit(10).
|
||||
Find(&articles)
|
||||
|
||||
// Load all tags for sidebar
|
||||
var tags []models.Tag
|
||||
db.Order("count DESC, name_zh ASC").Find(&tags)
|
||||
|
||||
data["Articles"] = articles
|
||||
data["Tags"] = tags
|
||||
if tagSlug != "" {
|
||||
data["FilterTag"] = tagSlug
|
||||
}
|
||||
|
||||
// ... existing comment counts logic ...
|
||||
|
||||
c.HTML(http.StatusOK, "home", data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Add `SearchPage` handler:**
|
||||
```go
|
||||
func SearchPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
keyword := strings.TrimSpace(c.Query("q"))
|
||||
// Search in title, summary, and content
|
||||
// Render search results template
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Create database models** (`models/tag.go`, `models/article_tag.go`)
|
||||
2. **Update article model** to add Tags relationship
|
||||
3. **Update database migration** (`models/db.go`)
|
||||
4. **Add tag helper functions** in `models/tag.go`
|
||||
5. **Add i18n translations** (`i18n/i18n.go`)
|
||||
6. **Update article handlers** (`handlers/article.go`) - form parsing, tag syncing
|
||||
7. **Update article templates** (`templates/admin/article_create.html`) - add tag input
|
||||
8. **Update home handler** (`handlers/home.go`) - add tag filtering, search
|
||||
9. **Update home template** (`templates/pages/home.html`) - add search box, tag sidebar
|
||||
10. **Create search page template** (`templates/pages/search.html`)
|
||||
11. **Add routes** (`main.go`)
|
||||
12. **Test the feature**
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Tag Storage
|
||||
- Store both Chinese and English names for multi-language support
|
||||
- Use slug for URL-friendly tag filtering
|
||||
- Cache article count in tag table for performance
|
||||
|
||||
### Tag Input Format
|
||||
- Simple comma-separated text input for ease of use
|
||||
- Auto-trim whitespace
|
||||
- No autocomplete in MVP (can be added later)
|
||||
|
||||
### Tag Creation
|
||||
- Auto-create tags on article save
|
||||
- No separate admin UI for tag management in MVP
|
||||
- Tags are created based on article associations
|
||||
|
||||
### Search Implementation
|
||||
- Full-text search in title, summary, and content fields
|
||||
- Case-insensitive matching
|
||||
- Simple LIKE query (can be upgraded to full-text search later)
|
||||
- Support pagination
|
||||
|
||||
### Tag Filtering
|
||||
- Filter by single tag via query parameter `?tag=slug`
|
||||
- Show filtered tag in UI for user clarity
|
||||
- Compatible with infinite scroll
|
||||
|
||||
### UI Layout
|
||||
- Search box prominent in header area
|
||||
- Tag sidebar on right (desktop only, hidden on mobile)
|
||||
- Tag cloud style with article count badges
|
||||
- Responsive design maintained
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Create article with tags
|
||||
- [ ] Tags are saved and associated with article
|
||||
- [ ] Non-existent tags are created automatically
|
||||
- [ ] Edit article - existing tags are shown in form
|
||||
- [ ] Edit article - add/remove tags works
|
||||
- [ ] Tags appear in homepage sidebar
|
||||
- [ ] Tag count is accurate
|
||||
- [ ] Click tag filters articles correctly
|
||||
- [ ] Search box appears in header
|
||||
- [ ] Search by keyword finds matching articles
|
||||
- [ ] Search works with title matches
|
||||
- [ ] Search works with content matches
|
||||
- [ ] Tag filtering + search can work together
|
||||
- [ ] Pagination works with filters
|
||||
- [ ] Infinite scroll works with filters
|
||||
- [ ] Mobile responsive layout maintained
|
||||
- [ ] Multi-language support works (zh/en)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Tag management admin page (rename, merge, delete tags)
|
||||
- Tag autocomplete/suggestions in article form
|
||||
- Tag popularity visualization
|
||||
- Related articles by shared tags
|
||||
- Full-text search engine (Elasticsearch, etc.)
|
||||
- Search suggestions/autocomplete
|
||||
- Advanced search filters (by date, author, etc.)
|
||||
Reference in New Issue
Block a user