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:
+4
-2
@@ -16,9 +16,11 @@ JWT_SECRET_KEY="a-very-secret-key-that-should-be-in-env"
|
||||
# Dev: Uses local Rustfs container (see compose.yml)
|
||||
# Prod: Use Cloudflare R2 credentials
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_ACCESS_KEY=minioadmin
|
||||
S3_SECRET_KEY=minioadmin
|
||||
S3_PUBLIC_URL=http://192.168.1.135:9000
|
||||
S3_ACCESS_KEY=rustfsadmin
|
||||
S3_SECRET_KEY=rustfsadmin
|
||||
S3_BUCKET=crussell
|
||||
AWS_REGION=eu-west-2
|
||||
|
||||
# Prod: Uncomment and fill these for R2
|
||||
# R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
|
||||
|
||||
@@ -169,6 +169,7 @@ docker compose exec backend sh
|
||||
| VAT System | ✅ | ❌ | `get_vat_return_data()`, `calculate_vat()` functions exist |
|
||||
| User Referrals | ✅ | ❌ | `user_referrals` table, backend logic exists |
|
||||
| Token Refresh | ✅ | ✅ | POST /api/refresh-token, auto-refresh in auth store |
|
||||
| Portfolio System | ✅ | ✅ | S3/R2 storage abstraction, tag-based filtering, category filters, admin upload, ?img= featured image param |
|
||||
|
||||
### ⚠️ Partially Complete
|
||||
|
||||
@@ -185,7 +186,6 @@ docker compose exec backend sh
|
||||
|---------|------|-------|
|
||||
| Social Auth | `handlers/auth/social.go` | OAuth integration placeholder |
|
||||
| Analytics | `handlers/admin/analytics.go` | Statistics/dashboard endpoint |
|
||||
| Portfolio Images | `handlers/portfolio/images.go` | Gallery management |
|
||||
|
||||
### 🚧 Critical TODOs
|
||||
|
||||
@@ -203,7 +203,6 @@ docker compose exec backend sh
|
||||
|---------|-------------|
|
||||
| One-off Custom Services | Allow creating single-use services not in regular catalog |
|
||||
| One-off Exceptional Hours | Single-day overrides without creating a group |
|
||||
| S3/R2 Image Hosting | Portfolio image storage with admin upload |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -32,29 +32,48 @@ type S3Client struct {
|
||||
}
|
||||
|
||||
func Connect() error {
|
||||
endpoint := os.Getenv("S3_ENDPOINT")
|
||||
// Check for RUSTFS_* vars first (matching compose.yml), fall back to S3_* vars
|
||||
endpoint := os.Getenv("RUSTFS_ENDPOINT")
|
||||
if endpoint == "" {
|
||||
endpoint = os.Getenv("S3_ENDPOINT")
|
||||
}
|
||||
if endpoint == "" {
|
||||
// Default: localhost for bare metal dev, use rustfs:9000 for docker
|
||||
endpoint = "http://localhost:9000"
|
||||
}
|
||||
|
||||
accessKey := os.Getenv("S3_ACCESS_KEY")
|
||||
accessKey := os.Getenv("RUSTFS_ACCESS_KEY")
|
||||
if accessKey == "" {
|
||||
accessKey = os.Getenv("S3_ACCESS_KEY")
|
||||
}
|
||||
if accessKey == "" {
|
||||
accessKey = "minioadmin"
|
||||
}
|
||||
|
||||
secretKey := os.Getenv("S3_SECRET_KEY")
|
||||
secretKey := os.Getenv("RUSTFS_SECRET_KEY")
|
||||
if secretKey == "" {
|
||||
secretKey = os.Getenv("S3_SECRET_KEY")
|
||||
}
|
||||
if secretKey == "" {
|
||||
secretKey = "minioadmin"
|
||||
}
|
||||
|
||||
bucket := os.Getenv("S3_BUCKET")
|
||||
bucket := os.Getenv("RUSTFS_BUCKET")
|
||||
if bucket == "" {
|
||||
bucket = os.Getenv("S3_BUCKET")
|
||||
}
|
||||
if bucket == "" {
|
||||
bucket = "crussell"
|
||||
}
|
||||
|
||||
region := os.Getenv("AWS_REGION")
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
region = "eu-west-2"
|
||||
}
|
||||
|
||||
publicURL := os.Getenv("S3_PUBLIC_URL")
|
||||
if publicURL == "" {
|
||||
publicURL = endpoint
|
||||
}
|
||||
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(),
|
||||
@@ -75,7 +94,39 @@ func Connect() error {
|
||||
o.UsePathStyle = true
|
||||
}),
|
||||
bucket: bucket,
|
||||
publicURL: endpoint,
|
||||
publicURL: publicURL,
|
||||
}
|
||||
|
||||
// Create bucket if it doesn't exist
|
||||
ctx := context.Background()
|
||||
s3Client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
||||
o.BaseEndpoint = aws.String(endpoint)
|
||||
o.UsePathStyle = true
|
||||
})
|
||||
_, err = s3Client.CreateBucket(ctx, &s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucket),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Bucket creation: %v (may already exist)", err)
|
||||
}
|
||||
|
||||
// Set bucket policy for public read access
|
||||
policy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Sid": "PublicReadGetObject",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::%s/*"
|
||||
}]
|
||||
}`, bucket)
|
||||
_, err = s3Client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Policy: aws.String(policy),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Bucket policy: %v (may already exist)", err)
|
||||
}
|
||||
|
||||
log.Printf("Connected to local S3 (Rustfs): bucket=%s, endpoint=%s", bucket, endpoint)
|
||||
@@ -87,6 +138,7 @@ func (s *S3Client) Upload(ctx context.Context, bucket, key string, body io.Reade
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
Body: body,
|
||||
ContentType: aws.String("image/jpeg"),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
authHandlers "crussell/handlers/auth"
|
||||
"crussell/handlers/bookings"
|
||||
"crussell/handlers/notifications"
|
||||
"crussell/handlers/portfolio"
|
||||
"crussell/handlers/scheduling"
|
||||
"crussell/handlers/services"
|
||||
"crussell/handlers/today"
|
||||
@@ -85,6 +86,21 @@ func main() {
|
||||
r.Post("/register", authHandlers.RegisterHandler)
|
||||
r.Post("/login", authHandlers.LoginHandler)
|
||||
|
||||
// --- Portfolio Routes (public read, admin write) ---
|
||||
r.Route("/portfolio", func(r chi.Router) {
|
||||
r.Get("/images", portfolio.ListImages)
|
||||
r.Get("/tags", portfolio.ListTags)
|
||||
r.Get("/filters", portfolio.ListFilters)
|
||||
r.Get("/images/{id}", portfolio.GetImage)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Use(mw.RequireAdmin)
|
||||
r.Post("/images", portfolio.UploadImage)
|
||||
r.Delete("/images/{id}", portfolio.DeleteImage)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Scheduling Routes ---
|
||||
r.Route("/scheduling", func(r chi.Router) {
|
||||
// Public GET routes
|
||||
|
||||
+7
-5
@@ -77,16 +77,18 @@ services:
|
||||
container_name: rustfs
|
||||
restart: always
|
||||
ports:
|
||||
- "9001:9000" # Web console
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
environment:
|
||||
- RUSTFS_ADMIN_ACCESS_KEY=minioadmin
|
||||
- RUSTFS_ADMIN_SECRET_KEY=minioadmin
|
||||
- RUSTFS_ADMIN_ACCESS_KEY=rustfsadmin
|
||||
- RUSTFS_ADMIN_SECRET_KEY=rustfsadmin
|
||||
- RUSTFS_ACCESS_KEY=rustfsadmin
|
||||
- RUSTFS_SECRET_KEY=rustfsadmin
|
||||
- RUSTFS_BUCKET=crussell
|
||||
volumes:
|
||||
- rustfs_data:/data
|
||||
networks:
|
||||
- appnet
|
||||
command: >
|
||||
bash -c "rustfs server /data --console-address :9000 --address :9000 --axum-server"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
@@ -128,3 +128,11 @@
|
||||
[data-calendar] {
|
||||
z-index: 49 !important;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
||||
@@ -253,7 +253,6 @@
|
||||
users = (data.users || []).filter(
|
||||
(user: { account_role: string }) => !excludedRoles.includes(user.account_role)
|
||||
);
|
||||
console.log(users);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch users', err);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import FileDropZone from '$lib/components/ui/file-drop-zone.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
|
||||
// =============== Image Upload ===============
|
||||
let uploading = $state(false);
|
||||
@@ -80,18 +82,21 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a 250×250 thumbnail (square, center-cropped). */
|
||||
/** Create a 250×250 thumbnail. First scale down so short side is 250px, then center-crop to 250×250 square. */
|
||||
function createThumbnail(blob: Blob): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const targetShortSide = 250;
|
||||
const thumbSize = 250;
|
||||
const { width, height } = img;
|
||||
let { width, height } = img;
|
||||
|
||||
// Scale up *or* down so that the image covers 250×250
|
||||
const scale = Math.max(thumbSize / width, thumbSize / height);
|
||||
const scaledW = Math.round(width * scale);
|
||||
const scaledH = Math.round(height * scale);
|
||||
const shortSide = Math.min(width, height);
|
||||
if (shortSide > targetShortSide) {
|
||||
const scale = targetShortSide / shortSide;
|
||||
width = Math.round(width * scale);
|
||||
height = Math.round(height * scale);
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = thumbSize;
|
||||
@@ -99,18 +104,12 @@
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return reject(new Error('2D context not available'));
|
||||
|
||||
// Draw the scaled image, then crop the center 250×250
|
||||
ctx.drawImage(
|
||||
img,
|
||||
(scaledW - thumbSize) / -2, // offset to center
|
||||
(scaledH - thumbSize) / -2,
|
||||
scaledW,
|
||||
scaledH,
|
||||
0,
|
||||
0,
|
||||
thumbSize,
|
||||
thumbSize
|
||||
);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const destX = (thumbSize - width) / 2;
|
||||
const destY = (thumbSize - height) / 2;
|
||||
ctx.drawImage(img, 0, 0, img.width, img.height, destX, destY, width, height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
@@ -126,32 +125,42 @@
|
||||
});
|
||||
}
|
||||
|
||||
const knownTags = [
|
||||
'portfolio',
|
||||
'gel',
|
||||
'acrylic',
|
||||
'french',
|
||||
'ombre',
|
||||
'summer',
|
||||
'wedding',
|
||||
'holiday',
|
||||
'pink',
|
||||
'red',
|
||||
'style:french',
|
||||
'style:minimal',
|
||||
'colour:red',
|
||||
'colour:pink',
|
||||
'season:summer'
|
||||
];
|
||||
let availableTags = $state<string[]>([]);
|
||||
let loadingTags = $state(true);
|
||||
let isMobile = $state(false);
|
||||
|
||||
async function fetchTags() {
|
||||
try {
|
||||
const response = await fetch('/api/portfolio/tags');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
availableTags = data.map((t: { name: string }) => t.name);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch tags:', e);
|
||||
} finally {
|
||||
loadingTags = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for mobile on mount
|
||||
if (typeof window !== 'undefined') {
|
||||
isMobile = window.matchMedia('(pointer: coarse)').matches;
|
||||
}
|
||||
|
||||
fetchTags();
|
||||
|
||||
let tags = $state<string[]>([]);
|
||||
let input = $state('');
|
||||
let selectedSuggestionIndex = $state(-1);
|
||||
let inputRef = $state<HTMLInputElement | undefined>(undefined);
|
||||
let showConfirmUploadAlert = $state(false);
|
||||
|
||||
const suggestions = $derived.by(() => {
|
||||
const q = input.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
|
||||
return knownTags
|
||||
return availableTags
|
||||
.map((t) => t.toLowerCase())
|
||||
.filter((t) => t.startsWith(q) && !tags.includes(t))
|
||||
.slice(0, 6);
|
||||
@@ -164,6 +173,16 @@
|
||||
addTag(value);
|
||||
input = '';
|
||||
}
|
||||
selectedSuggestionIndex = -1;
|
||||
}
|
||||
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
const value = e.clipboardData?.getData('text') || '';
|
||||
if (value.includes(',')) {
|
||||
e.preventDefault();
|
||||
addTag(value);
|
||||
input = '';
|
||||
}
|
||||
}
|
||||
|
||||
function isSemantic(tag: string) {
|
||||
@@ -172,19 +191,96 @@
|
||||
|
||||
function addTag(raw: string) {
|
||||
raw.split(',').forEach((p) => {
|
||||
const t = p.trim().toLowerCase();
|
||||
if (t && !tags.includes(t)) tags = [...tags, t];
|
||||
let t = p.trim().toLowerCase();
|
||||
if (!t) return;
|
||||
t = t.replace(/^colour:/, 'color:');
|
||||
if (!tags.includes(t)) tags = [...tags, t];
|
||||
});
|
||||
}
|
||||
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
// Prevent tab from moving focus away from this input
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
// Allow arrow key navigation when tab is pressed
|
||||
if (e.shiftKey) {
|
||||
// Shift+Tab - go to previous suggestion (wrap to end)
|
||||
if (suggestions.length > 0) {
|
||||
if (selectedSuggestionIndex <= 0) {
|
||||
selectedSuggestionIndex = suggestions.length - 1;
|
||||
} else {
|
||||
selectedSuggestionIndex = selectedSuggestionIndex - 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Tab - go to next suggestion (wrap to start)
|
||||
if (suggestions.length > 0) {
|
||||
if (selectedSuggestionIndex >= suggestions.length - 1) {
|
||||
selectedSuggestionIndex = 0;
|
||||
} else {
|
||||
selectedSuggestionIndex = selectedSuggestionIndex + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle mobile keyboard action keys
|
||||
const isActionKey = ['Enter', 'Done', 'Go'].includes(e.key);
|
||||
|
||||
if (isActionKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (suggestions.length > 0) {
|
||||
// Wrap to start if at end
|
||||
if (selectedSuggestionIndex >= suggestions.length - 1) {
|
||||
selectedSuggestionIndex = 0;
|
||||
} else {
|
||||
selectedSuggestionIndex = Math.min(selectedSuggestionIndex + 1, suggestions.length - 1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (suggestions.length > 0) {
|
||||
// Wrap to end if at start
|
||||
if (selectedSuggestionIndex <= 0) {
|
||||
selectedSuggestionIndex = suggestions.length - 1;
|
||||
} else {
|
||||
selectedSuggestionIndex = Math.max(selectedSuggestionIndex - 1, -1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' || e.key === 'Done' || e.key === 'Go') {
|
||||
// If there's a highlighted suggestion, select it
|
||||
if (selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]) {
|
||||
selectSuggestion(suggestions[selectedSuggestionIndex]);
|
||||
return;
|
||||
}
|
||||
|
||||
// If input has text, add it as tag
|
||||
if (input.trim()) {
|
||||
addTag(input);
|
||||
input = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// If input is empty but we have tags, show confirmation to upload
|
||||
if (tags.length > 0) {
|
||||
showConfirmUploadAlert = true;
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace' && !input && tags.length) {
|
||||
tags = tags.slice(0, -1);
|
||||
}
|
||||
@@ -193,6 +289,7 @@
|
||||
function selectSuggestion(tag: string) {
|
||||
addTag(tag);
|
||||
input = '';
|
||||
selectedSuggestionIndex = -1;
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
@@ -201,6 +298,12 @@
|
||||
|
||||
/** Core upload function – now processes the images before sending. */
|
||||
async function uploadOneOrMany() {
|
||||
// Add any pending input as tag before uploading
|
||||
if (input.trim()) {
|
||||
addTag(input);
|
||||
input = '';
|
||||
}
|
||||
|
||||
if (!uploadFiles.length) return;
|
||||
uploading = true;
|
||||
uploadResults = [];
|
||||
@@ -226,21 +329,33 @@
|
||||
const thumbName = `${ts}_thumb.jpg`;
|
||||
|
||||
/* -------- 4. Attach to FormData -------------------------------- */
|
||||
fd.append('file', resizedBlob, baseName); // this will be the "original"
|
||||
fd.append('file', thumbBlob, thumbName); // the thumbnail
|
||||
fd.append('file', resizedBlob, baseName);
|
||||
fd.append('thumbnail', thumbBlob, thumbName);
|
||||
|
||||
/* -------- 5. Mock the API call --------------------------------- */
|
||||
await new Promise((r) => setTimeout(r, 500)); // Simulate network delay
|
||||
if (file.name.toLowerCase().includes('fail')) {
|
||||
if (tags.length > 0) {
|
||||
fd.append('tags', tags.join(','));
|
||||
}
|
||||
|
||||
/* -------- 5. Call the API ------------------------------------- */
|
||||
const response = await fetch('/api/portfolio/images', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: fd
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
uploadResults.push({
|
||||
name: file.name,
|
||||
error: 'Mocked API error'
|
||||
error: errText || `Server error: ${response.status}`
|
||||
});
|
||||
} else {
|
||||
// In a real app you would `await fetch('/api/upload', {method:'POST', body:fd})`
|
||||
const result = await response.json();
|
||||
uploadResults.push({
|
||||
name: file.name,
|
||||
url: `/images/${baseName}` // pretend this is the returned URL
|
||||
url: result.url
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
@@ -312,7 +427,12 @@
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-emerald-100 text-emerald-800'}"
|
||||
>
|
||||
{result.name}: {result.error ? `Failed: ${result.error}` : `Success: ${result.url}`}
|
||||
{result.name}: {result.error ? `Failed: ${result.error}` : `Success: `}<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-emerald-600">{result.url}</a
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -324,25 +444,22 @@
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex min-h-[38px] w-full flex-wrap gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:outline-none"
|
||||
class="flex min-h-[44px] w-full flex-wrap gap-1.5 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:outline-none"
|
||||
>
|
||||
{#each tags as tag (tag)}
|
||||
<span
|
||||
class="flex items-center gap-1 rounded-full px-2 py-0.5 text-xs
|
||||
class="flex items-center gap-1 rounded-full px-2 py-1 text-xs
|
||||
{isSemantic(tag) ? 'bg-indigo-100 text-indigo-800' : 'bg-emerald-100 text-emerald-800'}"
|
||||
>
|
||||
{tag}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 leading-none
|
||||
class="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full leading-none
|
||||
{isSemantic(tag)
|
||||
? 'text-indigo-700 hover:text-indigo-900'
|
||||
: 'text-emerald-700 hover:text-emerald-900'}"
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
removeTag(tag);
|
||||
}}
|
||||
? 'text-indigo-700 hover:bg-indigo-200'
|
||||
: 'text-emerald-700 hover:bg-emerald-200'}"
|
||||
onclick={() => removeTag(tag)}
|
||||
aria-label={`Remove ${tag}`}
|
||||
>
|
||||
×
|
||||
@@ -351,23 +468,31 @@
|
||||
{/each}
|
||||
|
||||
<input
|
||||
class="min-w-[120px] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
type="search"
|
||||
enterkeyhint="done"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
class="min-w-[100px] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
bind:this={inputRef}
|
||||
bind:value={input}
|
||||
onkeydown={handleKey}
|
||||
oninput={handleTagInput}
|
||||
onpaste={handlePaste}
|
||||
placeholder={tags.length ? '' : 'Add tags…'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if input.length && suggestions.length}
|
||||
<div class="absolute right-0 left-0 z-10 mt-1 rounded-md border bg-white shadow">
|
||||
{#each suggestions as s (s)}
|
||||
<div class="absolute right-0 left-0 z-10 mt-1 rounded-md border bg-white shadow-md">
|
||||
{#each suggestions as s, i (s)}
|
||||
{@const isHighlighted = isMobile ? i === 0 : i === selectedSuggestionIndex}
|
||||
<div
|
||||
class="cursor-pointer px-3 py-2 text-sm hover:bg-gray-100"
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
selectSuggestion(s);
|
||||
}}
|
||||
class="cursor-pointer px-3 py-3 text-sm touch-manipulation {isHighlighted
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'hover:bg-gray-100'}"
|
||||
onclick={() => selectSuggestion(s)}
|
||||
>
|
||||
{s}
|
||||
</div>
|
||||
@@ -390,3 +515,20 @@
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={showConfirmUploadAlert}>
|
||||
<AlertDialog.Content class="z-[60]">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Upload with {tags.length} tag{tags.length === 1 ? '' : 's'}?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
You have {tags.length} tag{tags.length === 1 ? '' : 's'} selected:
|
||||
<span class="font-medium">{tags.join(', ')}</span>.
|
||||
Ready to upload {uploadFiles.length} file{uploadFiles.length === 1 ? '' : 's'}?
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={uploadOneOrMany}>Upload</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
@@ -14,13 +14,25 @@ async function proxyRequest(request: Request, path: string) {
|
||||
try {
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete('host');
|
||||
headers.delete('content-length');
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
let fetchOptions: RequestInit = {};
|
||||
if (!['GET', 'HEAD'].includes(request.method)) {
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
body = request.body;
|
||||
fetchOptions.duplex = 'half';
|
||||
} else {
|
||||
body = await request.text();
|
||||
}
|
||||
}
|
||||
|
||||
const backendRes = await fetch(url, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: ['GET', 'HEAD'].includes(request.method)
|
||||
? undefined
|
||||
: await request.text()
|
||||
body,
|
||||
...fetchOptions
|
||||
});
|
||||
|
||||
// Forward everything transparently
|
||||
|
||||
@@ -1,151 +1,397 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Dialog, DialogContent, DialogOverlay } from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
interface PortfolioImage {
|
||||
id: string;
|
||||
full: string;
|
||||
thumb: string;
|
||||
timestamp: number;
|
||||
tag_names: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
let images: PortfolioImage[] = [];
|
||||
let loading = true;
|
||||
let error = false;
|
||||
interface FilterCategory {
|
||||
category: string;
|
||||
values: FilterValue[];
|
||||
}
|
||||
|
||||
// Hardcoded list of images for prototyping
|
||||
const imageFilenames = [
|
||||
'1764273996.JPG',
|
||||
'_DSC3194.JPG',
|
||||
'_DSC3195.JPG',
|
||||
'_DSC3196.JPG',
|
||||
'_DSC3197.JPG',
|
||||
'_DSC3198.JPG',
|
||||
'_DSC3200.JPG',
|
||||
'_DSC3201.JPG',
|
||||
'_DSC3202.JPG',
|
||||
'_DSC3203.JPG',
|
||||
'_DSC3204.JPG',
|
||||
'_DSC3205.JPG',
|
||||
'_DSC3206.JPG',
|
||||
'_DSC3207.JPG',
|
||||
'_DSC3208.JPG',
|
||||
'_DSC3209.JPG',
|
||||
'_DSC3210.JPG',
|
||||
'_DSC3211.JPG',
|
||||
'_DSC3213.JPG',
|
||||
'_DSC3214.JPG',
|
||||
'_DSC3216.JPG',
|
||||
'_DSC3219.JPG',
|
||||
'DSC_3223.JPG',
|
||||
'DSC_3224.JPG',
|
||||
'_DSC3225.JPG',
|
||||
'_DSC3226.JPG',
|
||||
'_DSC3227.JPG',
|
||||
'_DSC3228.JPG',
|
||||
'_DSC3229.JPG',
|
||||
'_DSC3230.JPG',
|
||||
'_DSC3231.JPG',
|
||||
'_DSC3232.JPG',
|
||||
'_DSC3233.JPG',
|
||||
'_DSC3234.JPG',
|
||||
'_DSC3235.JPG',
|
||||
'_DSC3236.JPG',
|
||||
'_DSC3237.JPG',
|
||||
'_DSC3238.JPG',
|
||||
'_DSC3239.JPG',
|
||||
'_DSC3240.JPG',
|
||||
'_DSC3241.JPG',
|
||||
'_DSC3242.JPG',
|
||||
'_DSC3243.JPG',
|
||||
'_DSC3244.JPG',
|
||||
'_DSC3245.JPG',
|
||||
'_DSC3250.JPG',
|
||||
'_DSC3251.JPG',
|
||||
'_DSC3259.JPG',
|
||||
'_DSC3260.JPG',
|
||||
'_DSC3261.JPG',
|
||||
'_DSC3262.JPG',
|
||||
'_DSC3263.JPG',
|
||||
'_DSC3264.JPG',
|
||||
'_DSC3265.JPG',
|
||||
'_DSC3266.JPG',
|
||||
'_DSC3267.JPG',
|
||||
'_DSC3268.JPG',
|
||||
'_DSC3269.JPG',
|
||||
'_DSC3270.JPG',
|
||||
'_DSC3271.JPG',
|
||||
'_DSC3272.JPG',
|
||||
'_DSC3273.JPG',
|
||||
'_DSC3274.JPG',
|
||||
'_DSC3275.JPG',
|
||||
'_DSC3276.JPG',
|
||||
'_DSC3277.JPG',
|
||||
'_DSC3278.JPG',
|
||||
'_DSC3279.JPG',
|
||||
'_DSC3280.JPG',
|
||||
'_DSC3281.JPG',
|
||||
'_DSC3282.JPG',
|
||||
'_DSC3283.JPG',
|
||||
'_DSC3284.JPG',
|
||||
'_DSC3285.JPG',
|
||||
'_DSC3288.JPG',
|
||||
'_DSC3289.JPG',
|
||||
'_DSC3290.JPG',
|
||||
'_DSC3291.JPG',
|
||||
'_DSC3292.JPG',
|
||||
'_DSC3293.JPG',
|
||||
'_DSC3294.JPG',
|
||||
'_DSC3295.JPG',
|
||||
'_DSC3296.JPG',
|
||||
'_DSC3297.JPG',
|
||||
'_DSC3298.JPG',
|
||||
'_DSC3299.JPG',
|
||||
'_DSC3300.JPG',
|
||||
'_DSC3301.JPG',
|
||||
'_DSC3302.JPG',
|
||||
'_DSC3303.JPG',
|
||||
'_DSC3304.JPG',
|
||||
'_DSC3305.JPG',
|
||||
'_DSC3306.JPG',
|
||||
'_DSC3308.JPG'
|
||||
];
|
||||
interface FilterValue {
|
||||
value: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let images = $state<PortfolioImage[]>([]);
|
||||
let loading = $state(true);
|
||||
let loadingMore = $state(false);
|
||||
let error = $state(false);
|
||||
let selectedTag = $state('');
|
||||
let selectedTags = $state<string[]>([]);
|
||||
let hasMore = $state(true);
|
||||
let offset = $state(0);
|
||||
const limit = 20;
|
||||
|
||||
let searchQuery = $state('');
|
||||
let filterCategories = $state<FilterCategory[]>([]);
|
||||
let selectedFilters = $state<Record<string, string>>({});
|
||||
let openDropdowns = $state<Record<string, boolean>>({});
|
||||
let dropdownPosition = $state<{ x: number; y: number } | null>(null);
|
||||
const DROPDOWN_WIDTH = 192; // w-48 = 12rem = 192px
|
||||
|
||||
function toggleDropdown(category: string, event: MouseEvent) {
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
|
||||
if (!openDropdowns[category]) {
|
||||
// Clamp dropdown position to stay within viewport
|
||||
let x = rect.left;
|
||||
const maxX = window.innerWidth - DROPDOWN_WIDTH;
|
||||
if (x > maxX) {
|
||||
x = maxX;
|
||||
}
|
||||
if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
dropdownPosition = { x, y: rect.bottom };
|
||||
openDropdowns = { [category]: true };
|
||||
} else {
|
||||
openDropdowns = {};
|
||||
dropdownPosition = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeAllDropdowns() {
|
||||
openDropdowns = {};
|
||||
dropdownPosition = null;
|
||||
}
|
||||
|
||||
function handleWindowClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Don't close if clicking anywhere inside a filter dropdown (button or its children)
|
||||
if (target.closest('.filter-dropdown')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close all dropdowns when clicking outside
|
||||
closeAllDropdowns();
|
||||
}
|
||||
|
||||
function buildFilterUrl(): string {
|
||||
const parts: string[] = [];
|
||||
if (selectedTags.length > 0) {
|
||||
parts.push(`tags=${encodeURIComponent(selectedTags.join(','))}`);
|
||||
} else if (selectedTag) {
|
||||
parts.push(`tag=${encodeURIComponent(selectedTag)}`);
|
||||
}
|
||||
for (const [category, value] of Object.entries(selectedFilters)) {
|
||||
if (value) {
|
||||
parts.push(`filter[${encodeURIComponent(category)}]=${encodeURIComponent(value)}`);
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? '?' + parts.join('&') : '';
|
||||
}
|
||||
|
||||
async function fetchFilters() {
|
||||
try {
|
||||
images = imageFilenames
|
||||
.map((file) => {
|
||||
const base = file.replace(/\.JPG$/i, '');
|
||||
// Use a simple hash of the filename as timestamp for sorting
|
||||
const timestamp = base.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
return {
|
||||
full: `/portfolio/${file}`,
|
||||
thumb: `/portfolio/${base}_thumb.jpg`,
|
||||
timestamp
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.timestamp - a.timestamp);
|
||||
const params = buildFilterUrl();
|
||||
const url = params ? `/api/portfolio/filters${params}` : '/api/portfolio/filters';
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
console.error('Filter fetch failed:', response.status, response.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
const data = await response.json();
|
||||
// Sort by total count (most images first)
|
||||
filterCategories = data.sort((a: FilterCategory, b: FilterCategory) => {
|
||||
const countA = a.values.reduce((sum: number, v: FilterValue) => sum + v.count, 0);
|
||||
const countB = b.values.reduce((sum: number, v: FilterValue) => sum + v.count, 0);
|
||||
return countB - countA;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch filters:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function buildImageUrl(): string {
|
||||
const parts: string[] = [`limit=${limit}`, `offset=${offset}`];
|
||||
|
||||
if (selectedTags.length > 0) {
|
||||
parts.push(`tags=${encodeURIComponent(selectedTags.join(','))}`);
|
||||
} else if (selectedTag) {
|
||||
parts.push(`tag=${encodeURIComponent(selectedTag)}`);
|
||||
}
|
||||
|
||||
for (const [category, value] of Object.entries(selectedFilters)) {
|
||||
if (value) {
|
||||
parts.push(`filter[${encodeURIComponent(category)}]=${encodeURIComponent(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return '?' + parts.join('&');
|
||||
}
|
||||
|
||||
async function fetchImages(append = false) {
|
||||
if (append) {
|
||||
loadingMore = true;
|
||||
} else {
|
||||
loading = true;
|
||||
error = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = '/api/portfolio/images' + buildImageUrl();
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const newImages = data.map(
|
||||
(img: {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnail_url: string;
|
||||
tag_names: string[];
|
||||
created_at: string;
|
||||
}) => ({
|
||||
id: img.id,
|
||||
full: img.url,
|
||||
thumb: img.thumbnail_url,
|
||||
tag_names: img.tag_names || [],
|
||||
created_at: img.created_at
|
||||
})
|
||||
);
|
||||
|
||||
if (append) {
|
||||
images = [...images, ...newImages];
|
||||
} else {
|
||||
images = newImages;
|
||||
}
|
||||
|
||||
hasMore = newImages.length === limit;
|
||||
} catch (e) {
|
||||
error = true;
|
||||
loading = false;
|
||||
console.error('Failed to load portfolio:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchImageById(id: string): Promise<PortfolioImage | null> {
|
||||
try {
|
||||
const response = await fetch(`/api/portfolio/images/${id}`);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const img = await response.json();
|
||||
return {
|
||||
id: img.id,
|
||||
full: img.url,
|
||||
thumb: img.thumbnail_url,
|
||||
tag_names: img.tag_names || [],
|
||||
created_at: img.created_at
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch image by ID:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (loadingMore || !hasMore) return;
|
||||
offset += limit;
|
||||
fetchImages(true);
|
||||
}
|
||||
|
||||
function applySearch() {
|
||||
offset = 0;
|
||||
if (searchQuery.includes(',')) {
|
||||
selectedTags = searchQuery
|
||||
.split(',')
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
selectedTag = '';
|
||||
} else if (searchQuery.trim()) {
|
||||
selectedTag = searchQuery.trim().toLowerCase();
|
||||
selectedTags = [];
|
||||
} else {
|
||||
selectedTag = '';
|
||||
selectedTags = [];
|
||||
}
|
||||
|
||||
// Build URL with reactive page state
|
||||
const url = new URL(page.url);
|
||||
if (selectedTags.length > 0) {
|
||||
url.searchParams.set('tags', selectedTags.join(','));
|
||||
} else if (selectedTag) {
|
||||
url.searchParams.set('tag', selectedTag);
|
||||
} else {
|
||||
url.searchParams.delete('tag');
|
||||
url.searchParams.delete('tags');
|
||||
}
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
|
||||
|
||||
fetchImages(false);
|
||||
fetchFilters();
|
||||
}
|
||||
|
||||
function selectFilter(category: string, value: string) {
|
||||
offset = 0;
|
||||
closeAllDropdowns();
|
||||
|
||||
if (value === '' || value === selectedFilters[category]) {
|
||||
delete selectedFilters[category];
|
||||
selectedFilters = { ...selectedFilters };
|
||||
} else {
|
||||
selectedFilters = { ...selectedFilters, [category]: value };
|
||||
}
|
||||
|
||||
// Build URL with reactive page state
|
||||
const url = new URL(page.url);
|
||||
for (const [cat, val] of Object.entries(selectedFilters)) {
|
||||
if (val) {
|
||||
url.searchParams.set(`filter[${cat}]`, val);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
|
||||
|
||||
fetchImages(false);
|
||||
fetchFilters();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
selectedFilters = {};
|
||||
selectedTag = '';
|
||||
selectedTags = [];
|
||||
searchQuery = '';
|
||||
offset = 0;
|
||||
|
||||
// Build URL with reactive page state
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.delete('tag');
|
||||
url.searchParams.delete('tags');
|
||||
const keysToDelete = Array.from(url.searchParams.keys()).filter((k) => k.startsWith('filter['));
|
||||
keysToDelete.forEach((k) => url.searchParams.delete(k));
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
|
||||
|
||||
fetchImages(false);
|
||||
fetchFilters();
|
||||
}
|
||||
|
||||
let sentinelRef: HTMLDivElement | undefined = $state(undefined);
|
||||
|
||||
onMount(() => {
|
||||
const tagParam = page.url.searchParams.get('tag');
|
||||
const tagsParam = page.url.searchParams.get('tags');
|
||||
const imgParam = page.url.searchParams.get('img');
|
||||
|
||||
selectedTag = tagParam || '';
|
||||
selectedTags = tagsParam
|
||||
? tagsParam
|
||||
.split(',')
|
||||
.map((t: string) => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
if (selectedTag) {
|
||||
searchQuery = selectedTag;
|
||||
} else if (selectedTags.length > 0) {
|
||||
searchQuery = selectedTags.join(', ');
|
||||
}
|
||||
|
||||
for (const [key, value] of page.url.searchParams.entries()) {
|
||||
const match = key.match(/^filter\[(.+)\]$/);
|
||||
if (match) {
|
||||
selectedFilters[match[1]] = value;
|
||||
}
|
||||
}
|
||||
|
||||
fetchFilters();
|
||||
fetchImages(false);
|
||||
|
||||
// Always fetch the image by ID - this bypasses filters and pagination
|
||||
// ensuring the featured image always loads
|
||||
if (imgParam) {
|
||||
const targetId = imgParam.trim();
|
||||
fetchImageById(targetId).then((img) => {
|
||||
if (img) {
|
||||
featuredImage = img;
|
||||
openModal(img.full, img.thumb);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let showModal = false;
|
||||
let selectedImage: string | null = null;
|
||||
let selectedThumb: string | null = null;
|
||||
let currentIndex = 0;
|
||||
let imageLoading = false;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loadingMore && !loading) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
|
||||
if (sentinelRef) {
|
||||
observer.observe(sentinelRef);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
let featuredImage = $state<PortfolioImage | null>(null);
|
||||
let showModal = $state(false);
|
||||
let selectedImage = $state<string | null>(null);
|
||||
let selectedThumb = $state<string | null>(null);
|
||||
let currentIndex = $state(0);
|
||||
let imageLoading = $state(false);
|
||||
let nextButtonRef = $state<HTMLButtonElement | undefined>(undefined);
|
||||
let prevButtonRef = $state<HTMLButtonElement | undefined>(undefined);
|
||||
|
||||
function openModal(img: string, thumb: string) {
|
||||
currentIndex = images.findIndex((i) => i.full === img);
|
||||
const idx = images.findIndex((i) => i.full === img);
|
||||
if (idx !== -1) {
|
||||
openModalByIndex(idx);
|
||||
} else {
|
||||
selectedImage = img;
|
||||
selectedThumb = thumb;
|
||||
imageLoading = true;
|
||||
showModal = true;
|
||||
}
|
||||
}
|
||||
|
||||
function openModalByIndex(index: number) {
|
||||
currentIndex = index;
|
||||
selectedImage = images[index].full;
|
||||
selectedThumb = images[index].thumb;
|
||||
imageLoading = true;
|
||||
showModal = true;
|
||||
|
||||
// Extract timestamp from URL for shorter sharing URL
|
||||
const imgUrl = images[index].full;
|
||||
const timestamp = imgUrl.split('/').pop()?.replace(/\.[^.]+$/, '') || images[index].id;
|
||||
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.set('img', timestamp);
|
||||
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const hasNext = currentIndex < images.length - 1;
|
||||
const hasPrev = currentIndex > 0;
|
||||
if (hasNext && nextButtonRef) {
|
||||
nextButtonRef.focus();
|
||||
} else if (hasPrev && prevButtonRef) {
|
||||
prevButtonRef.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function navigateNext() {
|
||||
if (currentIndex < images.length - 1) {
|
||||
@@ -153,6 +399,12 @@
|
||||
selectedImage = images[currentIndex].full;
|
||||
selectedThumb = images[currentIndex].thumb;
|
||||
imageLoading = true;
|
||||
// Update URL with new image timestamp
|
||||
const imgUrl = images[currentIndex].full;
|
||||
const timestamp = imgUrl.split('/').pop()?.replace(/\.[^.]+$/, '') || images[currentIndex].id;
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.set('img', timestamp);
|
||||
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +414,12 @@
|
||||
selectedImage = images[currentIndex].full;
|
||||
selectedThumb = images[currentIndex].thumb;
|
||||
imageLoading = true;
|
||||
// Update URL with new image timestamp
|
||||
const imgUrl = images[currentIndex].full;
|
||||
const timestamp = imgUrl.split('/').pop()?.replace(/\.[^.]+$/, '') || images[currentIndex].id;
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.set('img', timestamp);
|
||||
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,9 +455,20 @@
|
||||
target.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal = false;
|
||||
if (featuredImage) {
|
||||
featuredImage = null;
|
||||
}
|
||||
// Remove img param from URL
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.delete('img');
|
||||
goto(url.pathname + url.search, { replaceState: true, noScroll: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
<svelte:window onkeydown={handleKeydown} onclick={handleWindowClick} onscroll={closeAllDropdowns} />
|
||||
|
||||
{#if loading}
|
||||
<div class="flex min-h-screen items-center justify-center p-4">
|
||||
@@ -231,30 +500,154 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-3 gap-1 p-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8">
|
||||
<div class="p-2">
|
||||
<div class="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="scrollbar-hide flex max-w-full items-center gap-2 overflow-x-auto pb-1"
|
||||
onscroll={closeAllDropdowns}
|
||||
>
|
||||
{#if filterCategories.length > 0}
|
||||
{@const hasActiveFilters = Object.values(selectedFilters).some((v) => v && v !== '')}
|
||||
{#if hasActiveFilters || selectedTag || selectedTags.length > 0}
|
||||
<button
|
||||
class="filter-dropdown flex shrink-0 items-center justify-center"
|
||||
onclick={clearFilters}
|
||||
aria-label="Clear all filters"
|
||||
>
|
||||
<div
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full bg-red-100 text-red-600 hover:bg-red-200"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
{#each filterCategories as filter (filter.category)}
|
||||
{@const selectedValue = selectedFilters[filter.category]}
|
||||
{@const hasActiveFilter = selectedValue && selectedValue !== ''}
|
||||
<div class="filter-dropdown relative shrink-0">
|
||||
<button
|
||||
class="flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm transition-colors hover:bg-gray-50 {hasActiveFilter
|
||||
? 'border-primary bg-primary/10'
|
||||
: ''}"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleDropdown(filter.category, e);
|
||||
}}
|
||||
>
|
||||
<span class="capitalize">{filter.category}:</span>
|
||||
<span class="font-medium">{selectedValue || 'Any'}</span>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{#each filterCategories as filter (filter.category)}
|
||||
{@const selectedValue = selectedFilters[filter.category]}
|
||||
{#if openDropdowns[filter.category]}
|
||||
<div
|
||||
class="filter-dropdown absolute z-50 mt-1 max-h-60 w-48 overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg"
|
||||
style="position: fixed; left: {dropdownPosition?.x}px; top: {dropdownPosition?.y}px;"
|
||||
>
|
||||
<button
|
||||
class="filter-dropdown flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors hover:bg-fuchsia-50 {selectedValue ===
|
||||
''
|
||||
? 'bg-fuchsia-100 font-medium'
|
||||
: ''}"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
selectFilter(filter.category, '');
|
||||
}}
|
||||
>
|
||||
<span class="text-gray-500">Any</span>
|
||||
</button>
|
||||
{#each filter.values as value (value.value)}
|
||||
<button
|
||||
class="filter-dropdown flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors hover:bg-fuchsia-50 {selectedValue ===
|
||||
value.value
|
||||
? 'bg-fuchsia-100 font-medium'
|
||||
: ''}"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
selectFilter(filter.category, value.value);
|
||||
}}
|
||||
>
|
||||
<span class="capitalize">{value.value}</span>
|
||||
<span class="text-xs text-gray-400">({value.count})</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 lg:border-l lg:pl-4">
|
||||
<Input
|
||||
placeholder="Search tags"
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => {
|
||||
if ((e as KeyboardEvent).key === 'Enter') applySearch();
|
||||
}}
|
||||
/>
|
||||
<Button onclick={applySearch}>
|
||||
{loading ? 'Searching...' : 'Search'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-1 p-2 pt-0 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8">
|
||||
{#each images as img (img.full)}
|
||||
<button
|
||||
class="relative aspect-square overflow-hidden rounded-xs transition-opacity hover:opacity-90 active:opacity-75"
|
||||
on:click={() => openModal(img.full, img.thumb)}
|
||||
onclick={() => openModal(img.full, img.thumb)}
|
||||
aria-label="View full size portfolio item"
|
||||
>
|
||||
<img
|
||||
loading="lazy"
|
||||
src={img.thumb}
|
||||
alt="Portfolio thumbnail {img.timestamp}"
|
||||
alt="Portfolio thumbnail {img.id}"
|
||||
class="h-full w-full object-cover opacity-0 transition-opacity duration-300"
|
||||
on:load={handleImageLoad}
|
||||
on:error={handleImageError}
|
||||
onload={handleImageLoad}
|
||||
onerror={handleImageError}
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div bind:this={sentinelRef} class="h-4 w-full">
|
||||
{#if loadingMore}
|
||||
<div class="flex justify-center py-4">
|
||||
<div
|
||||
class="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-gray-900"
|
||||
></div>
|
||||
</div>
|
||||
{:else if !hasMore && images.length > 0}
|
||||
<p class="text-center text-sm text-gray-500">No more images to load</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showModal}
|
||||
<Dialog open={showModal} onOpenChange={(v) => (showModal = v)}>
|
||||
<DialogOverlay class="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm" />
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
class="fixed top-1/2 left-1/2 z-50 max-h-[95vh] max-w-[95vw] -translate-x-1/2 -translate-y-1/2 border-0 bg-transparent p-0 shadow-none focus:outline-none sm:max-h-[90vh] sm:max-w-[90vw]"
|
||||
>
|
||||
<div class="relative flex items-center justify-center">
|
||||
@@ -274,14 +667,15 @@
|
||||
<img
|
||||
src={selectedImage ?? ''}
|
||||
alt="Portfolio full size"
|
||||
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain transition-opacity duration-300 sm:max-h-[90vh] sm:max-w-[90vw]"
|
||||
class:opacity-0={imageLoading}
|
||||
on:load={handleFullImageLoad}
|
||||
class="max-h-[95vh] max-w-[95vw] rounded-sm object-contain transition-opacity duration-300 sm:max-h-[90vh] sm:max-w-[90vw] {imageLoading
|
||||
? 'opacity-0'
|
||||
: ''}"
|
||||
onload={handleFullImageLoad}
|
||||
/>
|
||||
|
||||
<button
|
||||
class="absolute top-2 right-2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70 sm:top-4 sm:right-4"
|
||||
on:click={() => (showModal = false)}
|
||||
onclick={closeModal}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -296,8 +690,9 @@
|
||||
|
||||
{#if currentIndex > 0}
|
||||
<button
|
||||
bind:this={prevButtonRef}
|
||||
class="absolute top-1/2 left-2 -translate-y-1/2 rounded-full bg-black/50 p-3 text-white transition-colors hover:bg-black/70 sm:left-4"
|
||||
on:click={navigatePrev}
|
||||
onclick={navigatePrev}
|
||||
aria-label="Previous image"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -313,8 +708,9 @@
|
||||
|
||||
{#if currentIndex < images.length - 1}
|
||||
<button
|
||||
bind:this={nextButtonRef}
|
||||
class="absolute top-1/2 right-2 -translate-y-1/2 rounded-full bg-black/50 p-3 text-white transition-colors hover:bg-black/70 sm:right-4"
|
||||
on:click={navigateNext}
|
||||
onclick={navigateNext}
|
||||
aria-label="Next image"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -373,12 +373,13 @@ CREATE INDEX idx_payments_created_at_status ON payments(created_at, status);
|
||||
|
||||
create table images (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
r2_url text not null,
|
||||
tag_names text[] not null default '{}', -- for searching and filtering
|
||||
url text not null,
|
||||
thumbnail_url text not null,
|
||||
tag_names text[] not null default '{}',
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- only for autocomlpete text
|
||||
-- only for autocomplete text
|
||||
create table tags (
|
||||
id serial primary key,
|
||||
name text not null unique
|
||||
@@ -386,6 +387,7 @@ create table tags (
|
||||
|
||||
-- Indexes
|
||||
create index idx_images_tag_names on images using gin(tag_names);
|
||||
create index idx_images_tag_names_trgm on images using gin ((tag_names::text[]) gin_trgm_ops);
|
||||
create index idx_images_created_at on images(created_at desc);
|
||||
create index idx_tags_name_trgm on tags using gin (name gin_trgm_ops);
|
||||
|
||||
|
||||
+27
-7
@@ -43,11 +43,10 @@ docker compose up postgres -d > /dev/null 2>&1
|
||||
log_success "PostgreSQL reset complete"
|
||||
sleep 3
|
||||
|
||||
# --- 3b. Rustfs Reset (wipes data each run) ---
|
||||
log_step "Resetting Rustfs (S3-compatible storage)..."
|
||||
docker compose down -v rustfs > /dev/null 2>&1
|
||||
docker compose up rustfs -d > /dev/null 2>&1
|
||||
log_success "Rustfs reset complete"
|
||||
# --- 3b. Rustfs (don't wipe data, just restart) ---
|
||||
log_step "Restarting Rustfs (S3-compatible storage)..."
|
||||
docker compose restart rustfs > /dev/null 2>&1
|
||||
log_success "Rustfs restarted"
|
||||
sleep 2
|
||||
|
||||
# --- 4. Tmux Session Setup ---
|
||||
@@ -59,21 +58,42 @@ fi
|
||||
log_step "Starting tmux session '$SESSION_NAME'..."
|
||||
tmux new-session -d -s $SESSION_NAME -n "Workspace"
|
||||
|
||||
# Pass env vars to tmux session (must be after session creation)
|
||||
tmux set-environment -t $SESSION_NAME POSTGRES_USER "$POSTGRES_USER"
|
||||
tmux set-environment -t $SESSION_NAME POSTGRES_PASSWORD "$POSTGRES_PASSWORD"
|
||||
tmux set-environment -t $SESSION_NAME POSTGRES_DB "$POSTGRES_DB"
|
||||
tmux set-environment -t $SESSION_NAME POSTGRES_HOST "$POSTGRES_HOST"
|
||||
tmux set-environment -t $SESSION_NAME POSTGRES_PORT "$POSTGRES_PORT"
|
||||
tmux set-environment -t $SESSION_NAME JWT_SECRET_KEY "$JWT_SECRET_KEY"
|
||||
tmux set-environment -t $SESSION_NAME S3_ENDPOINT "$S3_ENDPOINT"
|
||||
tmux set-environment -t $SESSION_NAME S3_PUBLIC_URL "$S3_PUBLIC_URL"
|
||||
tmux set-environment -t $SESSION_NAME S3_ACCESS_KEY "$S3_ACCESS_KEY"
|
||||
tmux set-environment -t $SESSION_NAME S3_SECRET_KEY "$S3_SECRET_KEY"
|
||||
tmux set-environment -t $SESSION_NAME S3_BUCKET "$S3_BUCKET"
|
||||
tmux set-environment -t $SESSION_NAME AWS_REGION "$AWS_REGION"
|
||||
tmux set-environment -t $SESSION_NAME VITE_BACKEND_URL "$VITE_BACKEND_URL"
|
||||
|
||||
# Pane 0: Database
|
||||
# Start interactive shell only. Stats will be shown after seeding.
|
||||
tmux send-keys -t $SESSION_NAME "docker exec -it postgres psql -U myuser -d mydb" Enter
|
||||
tmux select-pane -t $SESSION_NAME:0.0 -T "DB"
|
||||
|
||||
# Pane 1: Backend (Split Horizontally)
|
||||
# Source .env to ensure all env vars are available to Go
|
||||
tmux split-window -v -t $SESSION_NAME
|
||||
tmux send-keys -t $SESSION_NAME "cd backend && go run -tags dev ./main.go" Enter
|
||||
tmux send-keys -t $SESSION_NAME "set -a; source .env > /dev/null 2>&1; cd backend && go run -tags dev ./main.go" Enter
|
||||
tmux select-pane -t $SESSION_NAME:0.1 -T "Backend"
|
||||
|
||||
# Pane 2: Frontend (Split Vertically from Backend)
|
||||
tmux split-window -h -t $SESSION_NAME:0.1
|
||||
tmux send-keys -t $SESSION_NAME "cd frontend && npm run dev -- --host" Enter
|
||||
tmux send-keys -t $SESSION_NAME "set -a; source .env > /dev/null 2>&1; cd frontend && npm run dev -- --host" Enter
|
||||
tmux select-pane -t $SESSION_NAME:0.2 -T "Frontend"
|
||||
|
||||
# Pane 3: Rustfs (S3-compatible storage) - Split from Frontend
|
||||
tmux split-window -v -t $SESSION_NAME:0.2
|
||||
tmux send-keys -t $SESSION_NAME "docker logs -f rustfs" Enter
|
||||
tmux select-pane -t $SESSION_NAME:0.3 -T "Rustfs"
|
||||
|
||||
# Layout configuration
|
||||
tmux select-layout -t $SESSION_NAME even-vertical
|
||||
tmux select-pane -t $SESSION_NAME:0.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
> **Last Updated:** January 2025
|
||||
> **Last Updated:** February 2026
|
||||
> **Status:** Work in Progress
|
||||
|
||||
---
|
||||
@@ -59,7 +59,7 @@
|
||||
#### Not Yet Wired
|
||||
- [ ] Social auth (`handlers/auth/social.go` exists, not imported)
|
||||
- [ ] Analytics (`handlers/admin/analytics.go` exists, not imported)
|
||||
- [ ] Portfolio/images (`handlers/portfolio/images.go` exists, not imported)
|
||||
- [x] Portfolio/images - NOW WIRED: `/api/portfolio/images`, `/api/portfolio/tags`, `/api/portfolio/filters`, `/api/portfolio/images/{id}`
|
||||
- [ ] Guest user endpoint (`/api/users/guest` - needed for walk-in bookings)
|
||||
|
||||
#### Unit Tests & CI/CD
|
||||
@@ -75,7 +75,7 @@
|
||||
- [x] Prices (`/prices`)
|
||||
- [x] Contact (`/contact`)
|
||||
- [x] Book (`/book`) - Full wizard with service selection, date/time, customer details
|
||||
- [ ] Portfolio (`/portfolio`) - Stubbed, needs S3/R2 integration for images
|
||||
- [x] Portfolio (`/portfolio`) - S3/R2 storage with tag filtering, category filters, pagination, ?img= featured image, admin upload
|
||||
- [x] Today (`/today`) - Admin only, real-time schedule view
|
||||
- [x] Account (`/account`)
|
||||
- [x] Login (`/login`)
|
||||
@@ -130,7 +130,7 @@
|
||||
- [x] CalDAV ready
|
||||
- [ ] Email/SMS reminders - not yet implemented
|
||||
- [ ] Square payment - placeholder only
|
||||
- [ ] S3/R2 image hosting - not configured
|
||||
- [x] S3/R2 image hosting - Rustfs for dev, Cloudflare R2 for prod via build tags
|
||||
|
||||
---
|
||||
|
||||
@@ -242,6 +242,25 @@ flowchart TD
|
||||
| DELETE | `/api/scheduling/exceptional-groups` | Delete exception group |
|
||||
| PUT | `/api/scheduling/exceptional-applications` | Apply exceptions to dates |
|
||||
|
||||
### Portfolio Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/portfolio/images` | List images with pagination, filter by tag/tags, filter by category:value |
|
||||
| GET | `/api/portfolio/tags` | List all tags for autocomplete (public) |
|
||||
| GET | `/api/portfolio/filters` | Get filter categories with counts. Supports tag filtering. Unselected categories show counts reduced by other filters. |
|
||||
| GET | `/api/portfolio/images/{id}` | Get single image by UUID or timestamp (fallback to URL pattern match) |
|
||||
| POST | `/api/portfolio/images` | Upload new image with thumbnail and tags (admin only) |
|
||||
| DELETE | `/api/portfolio/images/{id}` | Delete image (admin only) |
|
||||
|
||||
**Portfolio Frontend Features:**
|
||||
- `/portfolio` page with tag-based filtering and category filters
|
||||
- Category filters: `?filter[color]=red&filter[season]=summer`
|
||||
- Tag search: `?tag=toby` or `?tags=toby,summer`
|
||||
- Featured image: `?img=<id|timestamp>` - loads image directly, bypasses filters
|
||||
- Filter UI: scrollable dropdowns, keyboard navigation, mobile-optimized
|
||||
- Admin upload: ImageUpload component with live tag suggestions, keyboard nav, confirmation modal
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
@@ -287,6 +306,8 @@ admin_notification_reason: pending_booking | cancelled_booking | rescheduled_boo
|
||||
| `payments` | Payment transactions |
|
||||
| `business_settings` | Business configuration |
|
||||
| `admin_notifications` | Admin notification queue |
|
||||
| `images` | Portfolio gallery images with tags |
|
||||
| `tags` | Image tag autocomplete |
|
||||
| `user_notification_preferences` | User notification preferences (email/sms/push) |
|
||||
|
||||
### Key Functions
|
||||
@@ -332,7 +353,6 @@ admin_notification_reason: pending_booking | cancelled_booking | rescheduled_boo
|
||||
| **User notification preferences** | [x] DB table ready, waiting on user notification system |
|
||||
| **User notifications** | Notification system for regular users (booking confirmations, reminders) |
|
||||
| **Remove debug logs** | `console.log` in BookingFlow.svelte:600 and BookingCreateModal.svelte:224 |
|
||||
| **S3/R2 image hosting** | Portfolio image storage with admin upload |
|
||||
| **Loyalty display component** | Show stamps in account/bookings |
|
||||
| **Email/SMS reminders** | Scheduled notification jobs |
|
||||
| **Prometheus metrics** | Monitoring integration |
|
||||
@@ -343,7 +363,6 @@ admin_notification_reason: pending_booking | cancelled_booking | rescheduled_boo
|
||||
| ----------------------- | ----------------------------------- |
|
||||
| **Social auth** | Wire `handlers/auth/social.go` |
|
||||
| **Analytics** | Wire `handlers/admin/analytics.go` |
|
||||
| **Portfolio API** | Wire `handlers/portfolio/images.go` |
|
||||
| **nginx config review** | Finalize production config |
|
||||
| **CI/CD pipeline** | Gitea Actions workflow |
|
||||
| And many more | |
|
||||
|
||||
Reference in New Issue
Block a user