Portfolio: add filtering, URL sharing, and improved tag input
- Add category filters with dynamic counts that reduce as filters applied - Add ?filter[category]=value URL params for filterable links - Add ?img= timestamp param that bypasses filters to show specific image - Update URL when opening/navigating/closing modal for shareable links - Backend: add /api/portfolio/filters endpoint with filter logic - Backend: add timestamp lookup fallback for GetImage endpoint Frontend: - Portfolio page: filter dropdowns, keyboard nav, mobile improvements - ImageUpload: live tag suggestions from API, arrow/Tab navigation, confirmation modal before upload, mobile-optimized touch targets - Add scrollbar-hide utility and fix filter dropdown overflow - Move Clear all button, add vertical separator on desktop
This commit is contained in:
@@ -1,48 +1,650 @@
|
||||
package portfolio
|
||||
|
||||
// import (
|
||||
// "crussell/db"
|
||||
// "net/http"
|
||||
// )
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crussell/db"
|
||||
"crussell/internal/s3"
|
||||
"crussell/mw"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
// func GetImages(w http.ResponseWriter, r *http.Request) {
|
||||
// rows, err := db.DB.Query(r.Context(), `
|
||||
// select id, r2_url
|
||||
// from images
|
||||
// where
|
||||
// ($1::text[] is null or tag_names @> $1)
|
||||
// and ($2::text is null or exists (
|
||||
// select 1 from unnest(tag_names) as tag
|
||||
// where tag ilike '%' || $2 || '%'
|
||||
// ))
|
||||
// order by created_at desc
|
||||
// limit $3 offset $4
|
||||
// `,
|
||||
// semanticTags,
|
||||
// searchTerm,
|
||||
// limit,
|
||||
// offset,
|
||||
// )
|
||||
// }
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// func getAutoCompleteAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
// rows, err := db.DB.Query(r.Context(), `
|
||||
// select name
|
||||
// from tags
|
||||
// where name ilike '%' || $1 || '%'
|
||||
// order by similarity(name, $1) desc
|
||||
// limit 10
|
||||
// `, query)
|
||||
// }
|
||||
type Image struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
ThumbnailURL string `json:"thumbnail_url"`
|
||||
TagNames []string `json:"tag_names"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// func getAutoCompleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
// rows, err := db.DB.Query(r.Context(), `
|
||||
// select name
|
||||
// from tags
|
||||
// where
|
||||
// name not like '%:%'
|
||||
// and name ilike '%' || $1 || '%'
|
||||
// order by similarity(name, $1) desc
|
||||
// limit 10
|
||||
// `, query)
|
||||
// }
|
||||
type Tag struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func ListImages(w http.ResponseWriter, r *http.Request) {
|
||||
tagFilter := r.URL.Query().Get("tag")
|
||||
tagsFilter := r.URL.Query().Get("tags")
|
||||
limit := 20
|
||||
offset := 0
|
||||
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
if o := r.URL.Query().Get("offset"); o != "" {
|
||||
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
|
||||
offset = parsed
|
||||
}
|
||||
}
|
||||
|
||||
filterClauses := ""
|
||||
filterArgs := []interface{}{}
|
||||
|
||||
for key, values := range r.URL.Query() {
|
||||
if len(values) == 0 || values[0] == "" {
|
||||
continue
|
||||
}
|
||||
match, _ := regexp.Compile(`^filter\[(.+)\]$`)
|
||||
if match != nil {
|
||||
matches := match.FindStringSubmatch(key)
|
||||
if len(matches) == 2 {
|
||||
category := matches[1]
|
||||
value := values[0]
|
||||
filterClauses += fmt.Sprintf(" AND $%d = ANY(tag_names)", len(filterArgs)+1)
|
||||
filterArgs = append(filterArgs, category+":"+value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if tagsFilter != "" {
|
||||
tagList := strings.Split(tagsFilter, ",")
|
||||
cleanTags := make([]string, len(tagList))
|
||||
for i, t := range tagList {
|
||||
cleanTags[i] = strings.TrimSpace(t)
|
||||
}
|
||||
conditions := make([]string, len(cleanTags))
|
||||
for i := range cleanTags {
|
||||
conditions[i] = fmt.Sprintf("t ILIKE $%d", len(filterArgs)+i+1)
|
||||
}
|
||||
whereClause := strings.Join(conditions, " OR ")
|
||||
similarityCalls := make([]string, len(cleanTags))
|
||||
for i := range cleanTags {
|
||||
similarityCalls[i] = fmt.Sprintf("MAX(similarity(t, $%d))", len(filterArgs)+i+1)
|
||||
}
|
||||
similaritySum := strings.Join(similarityCalls, " + ")
|
||||
|
||||
argOffset := len(filterArgs)
|
||||
query = fmt.Sprintf(`
|
||||
SELECT id, url, thumbnail_url, tag_names, created_at,
|
||||
COUNT(t) as match_count,
|
||||
%s as relevance
|
||||
FROM images, unnest(tag_names) as t
|
||||
WHERE %s%s
|
||||
GROUP BY id, url, thumbnail_url, tag_names, created_at
|
||||
ORDER BY match_count DESC, relevance DESC, created_at DESC
|
||||
LIMIT $%d OFFSET $%d
|
||||
`, similaritySum, whereClause, filterClauses, argOffset+len(cleanTags)+1, argOffset+len(cleanTags)+2)
|
||||
|
||||
queryArgs := make([]interface{}, len(filterArgs)+len(cleanTags)+2)
|
||||
copy(queryArgs, filterArgs)
|
||||
for i, t := range cleanTags {
|
||||
queryArgs[len(filterArgs)+i] = t
|
||||
}
|
||||
queryArgs[len(filterArgs)+len(cleanTags)] = limit
|
||||
queryArgs[len(filterArgs)+len(cleanTags)+1] = offset
|
||||
args = queryArgs
|
||||
} else if tagFilter != "" {
|
||||
argOffset := len(filterArgs)
|
||||
searchPattern := "%" + tagFilter + "%"
|
||||
query = fmt.Sprintf(`
|
||||
SELECT id, url, thumbnail_url, tag_names, created_at,
|
||||
CASE WHEN t = $1 THEN 2 ELSE 1 END as match_priority,
|
||||
similarity(t, $1) as relevance
|
||||
FROM images, unnest(tag_names) as t
|
||||
WHERE t ILIKE '%%' || $1 || '%%%s'
|
||||
ORDER BY match_priority DESC, relevance DESC, created_at DESC
|
||||
LIMIT $%d OFFSET $%d
|
||||
`, filterClauses, argOffset+2, argOffset+3)
|
||||
|
||||
queryArgs := make([]interface{}, len(filterArgs)+3)
|
||||
queryArgs[0] = searchPattern
|
||||
copy(queryArgs[1:], filterArgs)
|
||||
queryArgs[len(filterArgs)+1] = limit
|
||||
queryArgs[len(filterArgs)+2] = offset
|
||||
args = queryArgs
|
||||
} else {
|
||||
argOffset := len(filterArgs)
|
||||
query = fmt.Sprintf(`
|
||||
SELECT id, url, thumbnail_url, tag_names, created_at, 0 as match_count, 0.0 as relevance
|
||||
FROM images
|
||||
WHERE 1=1%s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $%d OFFSET $%d
|
||||
`, filterClauses, argOffset+1, argOffset+2)
|
||||
|
||||
queryArgs := make([]interface{}, len(filterArgs)+2)
|
||||
copy(queryArgs, filterArgs)
|
||||
queryArgs[len(filterArgs)] = limit
|
||||
queryArgs[len(filterArgs)+1] = offset
|
||||
args = queryArgs
|
||||
}
|
||||
|
||||
rows, err := db.DB.Query(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
log.Printf("Failed to list images: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var images []Image
|
||||
for rows.Next() {
|
||||
var img Image
|
||||
var matchCount int
|
||||
var relevance float64
|
||||
if err := rows.Scan(&img.ID, &img.URL, &img.ThumbnailURL, (*[]string)(&img.TagNames), &img.CreatedAt, &matchCount, &relevance); err != nil {
|
||||
log.Printf("Failed to scan image: %v", err)
|
||||
continue
|
||||
}
|
||||
images = append(images, img)
|
||||
}
|
||||
|
||||
if images == nil {
|
||||
images = []Image{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(images)
|
||||
}
|
||||
|
||||
func ListTags(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
if q != "" {
|
||||
query = `
|
||||
SELECT id, name
|
||||
FROM tags
|
||||
WHERE name ILIKE $1
|
||||
ORDER BY name
|
||||
LIMIT 20
|
||||
`
|
||||
args = []interface{}{q + "%"}
|
||||
} else {
|
||||
query = `
|
||||
SELECT id, name
|
||||
FROM tags
|
||||
ORDER BY name
|
||||
LIMIT 20
|
||||
`
|
||||
}
|
||||
|
||||
rows, err := db.DB.Query(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
log.Printf("Failed to list tags: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tags []Tag
|
||||
for rows.Next() {
|
||||
var t Tag
|
||||
if err := rows.Scan(&t.ID, &t.Name); err != nil {
|
||||
log.Printf("Failed to scan tag: %v", err)
|
||||
continue
|
||||
}
|
||||
tags = append(tags, t)
|
||||
}
|
||||
|
||||
if tags == nil {
|
||||
tags = []Tag{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(tags)
|
||||
}
|
||||
|
||||
type FilterCategory struct {
|
||||
Category string `json:"category"`
|
||||
Values []FilterValue `json:"values"`
|
||||
}
|
||||
|
||||
type FilterValue struct {
|
||||
Value string `json:"value"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func ListFilters(w http.ResponseWriter, r *http.Request) {
|
||||
tagFilter := r.URL.Query().Get("tag")
|
||||
tagsFilter := r.URL.Query().Get("tags")
|
||||
|
||||
// Get all filter params
|
||||
selectedCategories := make(map[string]string)
|
||||
for key, values := range r.URL.Query() {
|
||||
if strings.HasPrefix(key, "filter[") && len(values) > 0 && values[0] != "" {
|
||||
category := strings.Trim(key, "[]")
|
||||
category = strings.TrimPrefix(category, "filter[")
|
||||
selectedCategories[category] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Build base query
|
||||
baseQuery := "SELECT DISTINCT id FROM images WHERE 1=1"
|
||||
args := []interface{}{}
|
||||
argNum := 1
|
||||
|
||||
if tagFilter != "" {
|
||||
searchPattern := "%" + tagFilter + "%"
|
||||
baseQuery += fmt.Sprintf(" AND EXISTS (SELECT 1 FROM unnest(tag_names) AS t WHERE t ILIKE $%d)", argNum)
|
||||
args = append(args, searchPattern)
|
||||
argNum++
|
||||
}
|
||||
if tagsFilter != "" {
|
||||
tagList := strings.Split(tagsFilter, ",")
|
||||
conditions := make([]string, len(tagList))
|
||||
for i := range tagList {
|
||||
tagList[i] = "%" + strings.TrimSpace(tagList[i]) + "%"
|
||||
conditions[i] = fmt.Sprintf("t ILIKE $%d", argNum)
|
||||
args = append(args, tagList[i])
|
||||
argNum++
|
||||
}
|
||||
baseQuery += " AND EXISTS (SELECT 1 FROM unnest(tag_names) AS t WHERE " + strings.Join(conditions, " OR ") + ")"
|
||||
}
|
||||
|
||||
// Build category filters for OTHER categories
|
||||
otherFilters := make([]string, 0)
|
||||
otherArgs := make([]interface{}, len(args))
|
||||
copy(otherArgs, args)
|
||||
otherArgNum := argNum
|
||||
for cat, val := range selectedCategories {
|
||||
tagValue := cat + ":" + val
|
||||
otherFilters = append(otherFilters, fmt.Sprintf("AND $%d = ANY(tag_names)", otherArgNum))
|
||||
otherArgs = append(otherArgs, tagValue)
|
||||
otherArgNum++
|
||||
}
|
||||
|
||||
// Build the filter count query
|
||||
filterQuery := `
|
||||
SELECT
|
||||
SPLIT_PART(t, ':', 1) as category,
|
||||
SPLIT_PART(t, ':', 2) as value,
|
||||
COUNT(*) as count
|
||||
FROM (` + baseQuery + `) as img_ids
|
||||
JOIN images ON images.id = img_ids.id
|
||||
JOIN unnest(images.tag_names) as t ON true
|
||||
WHERE t LIKE '%:%'
|
||||
`
|
||||
|
||||
// If there are selected categories, we need two queries
|
||||
if len(selectedCategories) > 0 {
|
||||
// Query 1: Get all categories with filters applied (for unselected categories)
|
||||
filterClause := ""
|
||||
if len(otherFilters) > 0 {
|
||||
filterClause = " " + strings.Join(otherFilters, " ")
|
||||
}
|
||||
|
||||
filteredQuery := filterQuery + filterClause + " GROUP BY category, value ORDER BY category, count DESC"
|
||||
|
||||
rows, err := db.DB.Query(r.Context(), filteredQuery, otherArgs...)
|
||||
if err != nil {
|
||||
log.Printf("Failed to list filters: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
results := make(map[string]map[string]int)
|
||||
for rows.Next() {
|
||||
var category, value string
|
||||
var count int
|
||||
if err := rows.Scan(&category, &value, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
// Skip selected categories in this query
|
||||
if _, isSelected := selectedCategories[category]; isSelected {
|
||||
continue
|
||||
}
|
||||
if results[category] == nil {
|
||||
results[category] = make(map[string]int)
|
||||
}
|
||||
results[category][value] = count
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Query 2: Get selected categories WITHOUT filters (show all options)
|
||||
unfilteredQuery := filterQuery + " GROUP BY category, value ORDER BY category, count DESC"
|
||||
rows, err = db.DB.Query(r.Context(), unfilteredQuery, args...)
|
||||
if err != nil {
|
||||
log.Printf("Failed to list unfiltered filters: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var category, value string
|
||||
var count int
|
||||
if err := rows.Scan(&category, &value, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
// Only include selected categories
|
||||
if _, isSelected := selectedCategories[category]; !isSelected {
|
||||
continue
|
||||
}
|
||||
if results[category] == nil {
|
||||
results[category] = make(map[string]int)
|
||||
}
|
||||
results[category][value] = count
|
||||
}
|
||||
|
||||
// Convert to response
|
||||
var filters []FilterCategory
|
||||
for cat, values := range results {
|
||||
var fv []FilterValue
|
||||
for val, cnt := range values {
|
||||
fv = append(fv, FilterValue{Value: val, Count: cnt})
|
||||
}
|
||||
sort.Slice(fv, func(i, j int) bool { return fv[i].Count > fv[j].Count })
|
||||
filters = append(filters, FilterCategory{Category: cat, Values: fv})
|
||||
}
|
||||
sort.Slice(filters, func(i, j int) bool {
|
||||
sumI := 0
|
||||
for _, v := range filters[i].Values {
|
||||
sumI += v.Count
|
||||
}
|
||||
sumJ := 0
|
||||
for _, v := range filters[j].Values {
|
||||
sumJ += v.Count
|
||||
}
|
||||
return sumI > sumJ
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(filters)
|
||||
return
|
||||
}
|
||||
|
||||
// No selected categories - simple query
|
||||
finalQuery := filterQuery + " GROUP BY category, value ORDER BY category, count DESC"
|
||||
|
||||
rows, err := db.DB.Query(r.Context(), finalQuery, args...)
|
||||
if err != nil {
|
||||
log.Printf("Failed to list filters: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var filters []FilterCategory
|
||||
currentCategory := ""
|
||||
var currentValues []FilterValue
|
||||
|
||||
for rows.Next() {
|
||||
var category, value string
|
||||
var count int
|
||||
if err := rows.Scan(&category, &value, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
if category != currentCategory {
|
||||
if currentCategory != "" {
|
||||
filters = append(filters, FilterCategory{Category: currentCategory, Values: currentValues})
|
||||
}
|
||||
currentCategory = category
|
||||
currentValues = []FilterValue{}
|
||||
}
|
||||
currentValues = append(currentValues, FilterValue{Value: value, Count: count})
|
||||
}
|
||||
if currentCategory != "" {
|
||||
filters = append(filters, FilterCategory{Category: currentCategory, Values: currentValues})
|
||||
}
|
||||
|
||||
// Deduplicate categories - use a map to ensure each category appears only once
|
||||
seenCategories := make(map[string]bool)
|
||||
var uniqueFilters []FilterCategory
|
||||
for _, f := range filters {
|
||||
if !seenCategories[f.Category] {
|
||||
seenCategories[f.Category] = true
|
||||
uniqueFilters = append(uniqueFilters, f)
|
||||
}
|
||||
}
|
||||
filters = uniqueFilters
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(filters)
|
||||
}
|
||||
|
||||
func UploadImage(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||||
if !ok || userID == "" {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
adminRole, _ := r.Context().Value(mw.UserRoleKey).(string)
|
||||
if adminRole != "admin" {
|
||||
http.Error(w, "Admin access required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if s3.Client == nil {
|
||||
log.Printf("S3 client not initialized")
|
||||
http.Error(w, "Storage not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
r.ParseMultipartForm(10 << 20)
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
log.Printf("Failed to get file: %v", err)
|
||||
http.Error(w, "No file provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
thumbFile, _, err := r.FormFile("thumbnail")
|
||||
if err != nil {
|
||||
log.Printf("Failed to get thumbnail: %v", err)
|
||||
http.Error(w, "No thumbnail provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer thumbFile.Close()
|
||||
|
||||
tagsStr := r.FormValue("tags")
|
||||
var tags []string
|
||||
if tagsStr != "" {
|
||||
tags = strings.Split(tagsStr, ",")
|
||||
for i := range tags {
|
||||
tags[i] = strings.TrimSpace(tags[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Use nanosecond timestamp for unique keys
|
||||
timestamp := time.Now().UnixNano()
|
||||
ext := ".jpg"
|
||||
if idx := strings.LastIndex(header.Filename, "."); idx != -1 {
|
||||
ext = strings.ToLower(header.Filename[idx:])
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("portfolio/%d%s", timestamp, ext)
|
||||
thumbKey := fmt.Sprintf("portfolio/%d_thumb%s", timestamp, ext)
|
||||
|
||||
fileBytes, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read file: %v", err)
|
||||
http.Error(w, "Failed to read file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
thumbBytes, err := io.ReadAll(thumbFile)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read thumbnail: %v", err)
|
||||
http.Error(w, "Failed to read thumbnail", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
bucket := "crussell"
|
||||
|
||||
if err := s3.Client.Upload(r.Context(), bucket, key, bytes.NewReader(fileBytes)); err != nil {
|
||||
log.Printf("Failed to upload image to S3: %v", err)
|
||||
http.Error(w, "Failed to upload image", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s3.Client.Upload(r.Context(), bucket, thumbKey, bytes.NewReader(thumbBytes)); err != nil {
|
||||
log.Printf("Failed to upload thumbnail to S3: %v", err)
|
||||
http.Error(w, "Failed to upload thumbnail", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
url, err := s3.Client.GetURL(r.Context(), bucket, key)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get image URL: %v", err)
|
||||
http.Error(w, "Failed to get URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
thumbURL, err := s3.Client.GetURL(r.Context(), bucket, thumbKey)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get thumbnail URL: %v", err)
|
||||
http.Error(w, "Failed to get thumbnail URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var imgID string
|
||||
err = db.DB.QueryRow(r.Context(), `
|
||||
INSERT INTO images (url, thumbnail_url, tag_names)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`, url, thumbURL, tags).Scan(&imgID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert image record: %v", err)
|
||||
http.Error(w, "Failed to save image record", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
_, err = db.DB.Exec(r.Context(), `
|
||||
INSERT INTO tags (name) VALUES ($1)
|
||||
ON CONFLICT (name) DO NOTHING
|
||||
`, tag)
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert tag %s: %v", tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(Image{
|
||||
ID: imgID,
|
||||
URL: url,
|
||||
ThumbnailURL: thumbURL,
|
||||
TagNames: tags,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteImage(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||||
if !ok || userID == "" {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
adminRole, _ := r.Context().Value(mw.UserRoleKey).(string)
|
||||
if adminRole != "admin" {
|
||||
http.Error(w, "Admin access required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
imageID := chi.URLParam(r, "id")
|
||||
|
||||
var url, thumbURL string
|
||||
err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT url, thumbnail_url FROM images WHERE id = $1
|
||||
`, imageID).Scan(&url, thumbURL)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to find image: %v", err)
|
||||
http.Error(w, "Image not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if s3.Client != nil {
|
||||
key := extractKey(url)
|
||||
thumbKey := extractKey(thumbURL)
|
||||
|
||||
s3.Client.Delete(context.Background(), "crussell", key)
|
||||
s3.Client.Delete(context.Background(), "crussell", thumbKey)
|
||||
}
|
||||
|
||||
_, err = db.DB.Exec(r.Context(), `DELETE FROM images WHERE id = $1`, imageID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to delete image: %v", err)
|
||||
http.Error(w, "Failed to delete image", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func extractKey(url string) string {
|
||||
parts := strings.Split(url, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
func GetImage(w http.ResponseWriter, r *http.Request) {
|
||||
imageID := chi.URLParam(r, "id")
|
||||
|
||||
var img Image
|
||||
err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT id, url, thumbnail_url, tag_names, created_at
|
||||
FROM images
|
||||
WHERE id = $1
|
||||
`, imageID).Scan(&img.ID, &img.URL, &img.ThumbnailURL, (*[]string)(&img.TagNames), &img.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
// Try to find by URL if UUID lookup failed
|
||||
// This allows using timestamps/keys from S3 URLs
|
||||
searchPattern := "%" + imageID + "%"
|
||||
err = db.DB.QueryRow(r.Context(), `
|
||||
SELECT id, url, thumbnail_url, tag_names, created_at
|
||||
FROM images
|
||||
WHERE url LIKE $1 OR thumbnail_url LIKE $1
|
||||
LIMIT 1
|
||||
`, searchPattern).Scan(&img.ID, &img.URL, &img.ThumbnailURL, (*[]string)(&img.TagNames), &img.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to get image: %v", err)
|
||||
http.Error(w, "Image not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(img)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user