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)
|
# Dev: Uses local Rustfs container (see compose.yml)
|
||||||
# Prod: Use Cloudflare R2 credentials
|
# Prod: Use Cloudflare R2 credentials
|
||||||
S3_ENDPOINT=http://localhost:9000
|
S3_ENDPOINT=http://localhost:9000
|
||||||
S3_ACCESS_KEY=minioadmin
|
S3_PUBLIC_URL=http://192.168.1.135:9000
|
||||||
S3_SECRET_KEY=minioadmin
|
S3_ACCESS_KEY=rustfsadmin
|
||||||
|
S3_SECRET_KEY=rustfsadmin
|
||||||
S3_BUCKET=crussell
|
S3_BUCKET=crussell
|
||||||
|
AWS_REGION=eu-west-2
|
||||||
|
|
||||||
# Prod: Uncomment and fill these for R2
|
# Prod: Uncomment and fill these for R2
|
||||||
# R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
|
# 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 |
|
| VAT System | ✅ | ❌ | `get_vat_return_data()`, `calculate_vat()` functions exist |
|
||||||
| User Referrals | ✅ | ❌ | `user_referrals` table, backend logic exists |
|
| User Referrals | ✅ | ❌ | `user_referrals` table, backend logic exists |
|
||||||
| Token Refresh | ✅ | ✅ | POST /api/refresh-token, auto-refresh in auth store |
|
| 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
|
### ⚠️ Partially Complete
|
||||||
|
|
||||||
@@ -185,7 +186,6 @@ docker compose exec backend sh
|
|||||||
|---------|------|-------|
|
|---------|------|-------|
|
||||||
| Social Auth | `handlers/auth/social.go` | OAuth integration placeholder |
|
| Social Auth | `handlers/auth/social.go` | OAuth integration placeholder |
|
||||||
| Analytics | `handlers/admin/analytics.go` | Statistics/dashboard endpoint |
|
| Analytics | `handlers/admin/analytics.go` | Statistics/dashboard endpoint |
|
||||||
| Portfolio Images | `handlers/portfolio/images.go` | Gallery management |
|
|
||||||
|
|
||||||
### 🚧 Critical TODOs
|
### 🚧 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 Custom Services | Allow creating single-use services not in regular catalog |
|
||||||
| One-off Exceptional Hours | Single-day overrides without creating a group |
|
| 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
|
package portfolio
|
||||||
|
|
||||||
// import (
|
import (
|
||||||
// "crussell/db"
|
"bytes"
|
||||||
// "net/http"
|
"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) {
|
"github.com/go-chi/chi/v5"
|
||||||
// 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,
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func getAutoCompleteAdmin(w http.ResponseWriter, r *http.Request) {
|
type Image struct {
|
||||||
// rows, err := db.DB.Query(r.Context(), `
|
ID string `json:"id"`
|
||||||
// select name
|
URL string `json:"url"`
|
||||||
// from tags
|
ThumbnailURL string `json:"thumbnail_url"`
|
||||||
// where name ilike '%' || $1 || '%'
|
TagNames []string `json:"tag_names"`
|
||||||
// order by similarity(name, $1) desc
|
CreatedAt time.Time `json:"created_at"`
|
||||||
// limit 10
|
}
|
||||||
// `, query)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func getAutoCompleteUser(w http.ResponseWriter, r *http.Request) {
|
type Tag struct {
|
||||||
// rows, err := db.DB.Query(r.Context(), `
|
ID int `json:"id"`
|
||||||
// select name
|
Name string `json:"name"`
|
||||||
// from tags
|
}
|
||||||
// where
|
|
||||||
// name not like '%:%'
|
func ListImages(w http.ResponseWriter, r *http.Request) {
|
||||||
// and name ilike '%' || $1 || '%'
|
tagFilter := r.URL.Query().Get("tag")
|
||||||
// order by similarity(name, $1) desc
|
tagsFilter := r.URL.Query().Get("tags")
|
||||||
// limit 10
|
limit := 20
|
||||||
// `, query)
|
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 {
|
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 == "" {
|
if endpoint == "" {
|
||||||
|
endpoint = os.Getenv("S3_ENDPOINT")
|
||||||
|
}
|
||||||
|
if endpoint == "" {
|
||||||
|
// Default: localhost for bare metal dev, use rustfs:9000 for docker
|
||||||
endpoint = "http://localhost:9000"
|
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 == "" {
|
if accessKey == "" {
|
||||||
accessKey = "minioadmin"
|
accessKey = "minioadmin"
|
||||||
}
|
}
|
||||||
|
|
||||||
secretKey := os.Getenv("S3_SECRET_KEY")
|
secretKey := os.Getenv("RUSTFS_SECRET_KEY")
|
||||||
|
if secretKey == "" {
|
||||||
|
secretKey = os.Getenv("S3_SECRET_KEY")
|
||||||
|
}
|
||||||
if secretKey == "" {
|
if secretKey == "" {
|
||||||
secretKey = "minioadmin"
|
secretKey = "minioadmin"
|
||||||
}
|
}
|
||||||
|
|
||||||
bucket := os.Getenv("S3_BUCKET")
|
bucket := os.Getenv("RUSTFS_BUCKET")
|
||||||
|
if bucket == "" {
|
||||||
|
bucket = os.Getenv("S3_BUCKET")
|
||||||
|
}
|
||||||
if bucket == "" {
|
if bucket == "" {
|
||||||
bucket = "crussell"
|
bucket = "crussell"
|
||||||
}
|
}
|
||||||
|
|
||||||
region := os.Getenv("AWS_REGION")
|
region := os.Getenv("AWS_REGION")
|
||||||
if 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(),
|
awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(),
|
||||||
@@ -75,7 +94,39 @@ func Connect() error {
|
|||||||
o.UsePathStyle = true
|
o.UsePathStyle = true
|
||||||
}),
|
}),
|
||||||
bucket: bucket,
|
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)
|
log.Printf("Connected to local S3 (Rustfs): bucket=%s, endpoint=%s", bucket, endpoint)
|
||||||
@@ -84,9 +135,10 @@ func Connect() error {
|
|||||||
|
|
||||||
func (s *S3Client) Upload(ctx context.Context, bucket, key string, body io.Reader) error {
|
func (s *S3Client) Upload(ctx context.Context, bucket, key string, body io.Reader) error {
|
||||||
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
Bucket: aws.String(bucket),
|
Bucket: aws.String(bucket),
|
||||||
Key: aws.String(key),
|
Key: aws.String(key),
|
||||||
Body: body,
|
Body: body,
|
||||||
|
ContentType: aws.String("image/jpeg"),
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
authHandlers "crussell/handlers/auth"
|
authHandlers "crussell/handlers/auth"
|
||||||
"crussell/handlers/bookings"
|
"crussell/handlers/bookings"
|
||||||
"crussell/handlers/notifications"
|
"crussell/handlers/notifications"
|
||||||
|
"crussell/handlers/portfolio"
|
||||||
"crussell/handlers/scheduling"
|
"crussell/handlers/scheduling"
|
||||||
"crussell/handlers/services"
|
"crussell/handlers/services"
|
||||||
"crussell/handlers/today"
|
"crussell/handlers/today"
|
||||||
@@ -85,6 +86,21 @@ func main() {
|
|||||||
r.Post("/register", authHandlers.RegisterHandler)
|
r.Post("/register", authHandlers.RegisterHandler)
|
||||||
r.Post("/login", authHandlers.LoginHandler)
|
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 ---
|
// --- Scheduling Routes ---
|
||||||
r.Route("/scheduling", func(r chi.Router) {
|
r.Route("/scheduling", func(r chi.Router) {
|
||||||
// Public GET routes
|
// Public GET routes
|
||||||
|
|||||||
+7
-5
@@ -77,16 +77,18 @@ services:
|
|||||||
container_name: rustfs
|
container_name: rustfs
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "9001:9000" # Web console
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
environment:
|
environment:
|
||||||
- RUSTFS_ADMIN_ACCESS_KEY=minioadmin
|
- RUSTFS_ADMIN_ACCESS_KEY=rustfsadmin
|
||||||
- RUSTFS_ADMIN_SECRET_KEY=minioadmin
|
- RUSTFS_ADMIN_SECRET_KEY=rustfsadmin
|
||||||
|
- RUSTFS_ACCESS_KEY=rustfsadmin
|
||||||
|
- RUSTFS_SECRET_KEY=rustfsadmin
|
||||||
|
- RUSTFS_BUCKET=crussell
|
||||||
volumes:
|
volumes:
|
||||||
- rustfs_data:/data
|
- rustfs_data:/data
|
||||||
networks:
|
networks:
|
||||||
- appnet
|
- appnet
|
||||||
command: >
|
|
||||||
bash -c "rustfs server /data --console-address :9000 --address :9000 --axum-server"
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
@@ -127,4 +127,12 @@
|
|||||||
|
|
||||||
[data-calendar] {
|
[data-calendar] {
|
||||||
z-index: 49 !important;
|
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 { toast } from 'svelte-sonner';
|
||||||
import * as Modal from '$lib/components/ui/dialog';
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|||||||
@@ -253,7 +253,6 @@
|
|||||||
users = (data.users || []).filter(
|
users = (data.users || []).filter(
|
||||||
(user: { account_role: string }) => !excludedRoles.includes(user.account_role)
|
(user: { account_role: string }) => !excludedRoles.includes(user.account_role)
|
||||||
);
|
);
|
||||||
console.log(users);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch users', err);
|
console.error('Failed to fetch users', err);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import FileDropZone from '$lib/components/ui/file-drop-zone.svelte';
|
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 ===============
|
// =============== Image Upload ===============
|
||||||
let uploading = $state(false);
|
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> {
|
function createThumbnail(blob: Blob): Promise<Blob> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
|
const targetShortSide = 250;
|
||||||
const thumbSize = 250;
|
const thumbSize = 250;
|
||||||
const { width, height } = img;
|
let { width, height } = img;
|
||||||
|
|
||||||
// Scale up *or* down so that the image covers 250×250
|
const shortSide = Math.min(width, height);
|
||||||
const scale = Math.max(thumbSize / width, thumbSize / height);
|
if (shortSide > targetShortSide) {
|
||||||
const scaledW = Math.round(width * scale);
|
const scale = targetShortSide / shortSide;
|
||||||
const scaledH = Math.round(height * scale);
|
width = Math.round(width * scale);
|
||||||
|
height = Math.round(height * scale);
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = thumbSize;
|
canvas.width = thumbSize;
|
||||||
@@ -99,18 +104,12 @@
|
|||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return reject(new Error('2D context not available'));
|
if (!ctx) return reject(new Error('2D context not available'));
|
||||||
|
|
||||||
// Draw the scaled image, then crop the center 250×250
|
ctx.imageSmoothingEnabled = true;
|
||||||
ctx.drawImage(
|
ctx.imageSmoothingQuality = 'high';
|
||||||
img,
|
|
||||||
(scaledW - thumbSize) / -2, // offset to center
|
const destX = (thumbSize - width) / 2;
|
||||||
(scaledH - thumbSize) / -2,
|
const destY = (thumbSize - height) / 2;
|
||||||
scaledW,
|
ctx.drawImage(img, 0, 0, img.width, img.height, destX, destY, width, height);
|
||||||
scaledH,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
thumbSize,
|
|
||||||
thumbSize
|
|
||||||
);
|
|
||||||
|
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(blob) => {
|
(blob) => {
|
||||||
@@ -126,32 +125,42 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const knownTags = [
|
let availableTags = $state<string[]>([]);
|
||||||
'portfolio',
|
let loadingTags = $state(true);
|
||||||
'gel',
|
let isMobile = $state(false);
|
||||||
'acrylic',
|
|
||||||
'french',
|
async function fetchTags() {
|
||||||
'ombre',
|
try {
|
||||||
'summer',
|
const response = await fetch('/api/portfolio/tags');
|
||||||
'wedding',
|
if (response.ok) {
|
||||||
'holiday',
|
const data = await response.json();
|
||||||
'pink',
|
availableTags = data.map((t: { name: string }) => t.name);
|
||||||
'red',
|
}
|
||||||
'style:french',
|
} catch (e) {
|
||||||
'style:minimal',
|
console.error('Failed to fetch tags:', e);
|
||||||
'colour:red',
|
} finally {
|
||||||
'colour:pink',
|
loadingTags = false;
|
||||||
'season:summer'
|
}
|
||||||
];
|
}
|
||||||
|
|
||||||
|
// Check for mobile on mount
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
isMobile = window.matchMedia('(pointer: coarse)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchTags();
|
||||||
|
|
||||||
let tags = $state<string[]>([]);
|
let tags = $state<string[]>([]);
|
||||||
let input = $state('');
|
let input = $state('');
|
||||||
|
let selectedSuggestionIndex = $state(-1);
|
||||||
|
let inputRef = $state<HTMLInputElement | undefined>(undefined);
|
||||||
|
let showConfirmUploadAlert = $state(false);
|
||||||
|
|
||||||
const suggestions = $derived.by(() => {
|
const suggestions = $derived.by(() => {
|
||||||
const q = input.trim().toLowerCase();
|
const q = input.trim().toLowerCase();
|
||||||
if (!q) return [];
|
if (!q) return [];
|
||||||
|
|
||||||
return knownTags
|
return availableTags
|
||||||
.map((t) => t.toLowerCase())
|
.map((t) => t.toLowerCase())
|
||||||
.filter((t) => t.startsWith(q) && !tags.includes(t))
|
.filter((t) => t.startsWith(q) && !tags.includes(t))
|
||||||
.slice(0, 6);
|
.slice(0, 6);
|
||||||
@@ -164,6 +173,16 @@
|
|||||||
addTag(value);
|
addTag(value);
|
||||||
input = '';
|
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) {
|
function isSemantic(tag: string) {
|
||||||
@@ -172,16 +191,93 @@
|
|||||||
|
|
||||||
function addTag(raw: string) {
|
function addTag(raw: string) {
|
||||||
raw.split(',').forEach((p) => {
|
raw.split(',').forEach((p) => {
|
||||||
const t = p.trim().toLowerCase();
|
let t = p.trim().toLowerCase();
|
||||||
if (t && !tags.includes(t)) tags = [...tags, t];
|
if (!t) return;
|
||||||
|
t = t.replace(/^colour:/, 'color:');
|
||||||
|
if (!tags.includes(t)) tags = [...tags, t];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKey(e: KeyboardEvent) {
|
function handleKey(e: KeyboardEvent) {
|
||||||
if (e.key === 'Enter') {
|
// Prevent tab from moving focus away from this input
|
||||||
|
if (e.key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
addTag(input);
|
// Allow arrow key navigation when tab is pressed
|
||||||
input = '';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +289,7 @@
|
|||||||
function selectSuggestion(tag: string) {
|
function selectSuggestion(tag: string) {
|
||||||
addTag(tag);
|
addTag(tag);
|
||||||
input = '';
|
input = '';
|
||||||
|
selectedSuggestionIndex = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTag(tag: string) {
|
function removeTag(tag: string) {
|
||||||
@@ -201,6 +298,12 @@
|
|||||||
|
|
||||||
/** Core upload function – now processes the images before sending. */
|
/** Core upload function – now processes the images before sending. */
|
||||||
async function uploadOneOrMany() {
|
async function uploadOneOrMany() {
|
||||||
|
// Add any pending input as tag before uploading
|
||||||
|
if (input.trim()) {
|
||||||
|
addTag(input);
|
||||||
|
input = '';
|
||||||
|
}
|
||||||
|
|
||||||
if (!uploadFiles.length) return;
|
if (!uploadFiles.length) return;
|
||||||
uploading = true;
|
uploading = true;
|
||||||
uploadResults = [];
|
uploadResults = [];
|
||||||
@@ -226,21 +329,33 @@
|
|||||||
const thumbName = `${ts}_thumb.jpg`;
|
const thumbName = `${ts}_thumb.jpg`;
|
||||||
|
|
||||||
/* -------- 4. Attach to FormData -------------------------------- */
|
/* -------- 4. Attach to FormData -------------------------------- */
|
||||||
fd.append('file', resizedBlob, baseName); // this will be the "original"
|
fd.append('file', resizedBlob, baseName);
|
||||||
fd.append('file', thumbBlob, thumbName); // the thumbnail
|
fd.append('thumbnail', thumbBlob, thumbName);
|
||||||
|
|
||||||
/* -------- 5. Mock the API call --------------------------------- */
|
if (tags.length > 0) {
|
||||||
await new Promise((r) => setTimeout(r, 500)); // Simulate network delay
|
fd.append('tags', tags.join(','));
|
||||||
if (file.name.toLowerCase().includes('fail')) {
|
}
|
||||||
|
|
||||||
|
/* -------- 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({
|
uploadResults.push({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
error: 'Mocked API error'
|
error: errText || `Server error: ${response.status}`
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// In a real app you would `await fetch('/api/upload', {method:'POST', body:fd})`
|
const result = await response.json();
|
||||||
uploadResults.push({
|
uploadResults.push({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
url: `/images/${baseName}` // pretend this is the returned URL
|
url: result.url
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -312,7 +427,12 @@
|
|||||||
? 'bg-red-100 text-red-800'
|
? 'bg-red-100 text-red-800'
|
||||||
: 'bg-emerald-100 text-emerald-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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -324,25 +444,22 @@
|
|||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div
|
<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)}
|
{#each tags as tag (tag)}
|
||||||
<span
|
<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'}"
|
{isSemantic(tag) ? 'bg-indigo-100 text-indigo-800' : 'bg-emerald-100 text-emerald-800'}"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="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)
|
{isSemantic(tag)
|
||||||
? 'text-indigo-700 hover:text-indigo-900'
|
? 'text-indigo-700 hover:bg-indigo-200'
|
||||||
: 'text-emerald-700 hover:text-emerald-900'}"
|
: 'text-emerald-700 hover:bg-emerald-200'}"
|
||||||
onmousedown={(e) => {
|
onclick={() => removeTag(tag)}
|
||||||
e.preventDefault();
|
|
||||||
removeTag(tag);
|
|
||||||
}}
|
|
||||||
aria-label={`Remove ${tag}`}
|
aria-label={`Remove ${tag}`}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@@ -351,23 +468,31 @@
|
|||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<input
|
<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}
|
bind:value={input}
|
||||||
onkeydown={handleKey}
|
onkeydown={handleKey}
|
||||||
oninput={handleTagInput}
|
oninput={handleTagInput}
|
||||||
|
onpaste={handlePaste}
|
||||||
placeholder={tags.length ? '' : 'Add tags…'}
|
placeholder={tags.length ? '' : 'Add tags…'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if input.length && suggestions.length}
|
{#if input.length && suggestions.length}
|
||||||
<div class="absolute right-0 left-0 z-10 mt-1 rounded-md border bg-white shadow">
|
<div class="absolute right-0 left-0 z-10 mt-1 rounded-md border bg-white shadow-md">
|
||||||
{#each suggestions as s (s)}
|
{#each suggestions as s, i (s)}
|
||||||
|
{@const isHighlighted = isMobile ? i === 0 : i === selectedSuggestionIndex}
|
||||||
<div
|
<div
|
||||||
class="cursor-pointer px-3 py-2 text-sm hover:bg-gray-100"
|
class="cursor-pointer px-3 py-3 text-sm touch-manipulation {isHighlighted
|
||||||
onmousedown={(e) => {
|
? 'bg-primary/10 text-primary font-medium'
|
||||||
e.preventDefault();
|
: 'hover:bg-gray-100'}"
|
||||||
selectSuggestion(s);
|
onclick={() => selectSuggestion(s)}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{s}
|
{s}
|
||||||
</div>
|
</div>
|
||||||
@@ -390,3 +515,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</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 {
|
try {
|
||||||
const headers = new Headers(request.headers);
|
const headers = new Headers(request.headers);
|
||||||
headers.delete('host');
|
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, {
|
const backendRes = await fetch(url, {
|
||||||
method: request.method,
|
method: request.method,
|
||||||
headers,
|
headers,
|
||||||
body: ['GET', 'HEAD'].includes(request.method)
|
body,
|
||||||
? undefined
|
...fetchOptions
|
||||||
: await request.text()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Forward everything transparently
|
// Forward everything transparently
|
||||||
|
|||||||
@@ -1,150 +1,396 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { Dialog, DialogContent, DialogOverlay } from '$lib/components/ui/dialog';
|
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 {
|
interface PortfolioImage {
|
||||||
|
id: string;
|
||||||
full: string;
|
full: string;
|
||||||
thumb: string;
|
thumb: string;
|
||||||
timestamp: number;
|
tag_names: string[];
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let images: PortfolioImage[] = [];
|
interface FilterCategory {
|
||||||
let loading = true;
|
category: string;
|
||||||
let error = false;
|
values: FilterValue[];
|
||||||
|
}
|
||||||
|
|
||||||
// Hardcoded list of images for prototyping
|
interface FilterValue {
|
||||||
const imageFilenames = [
|
value: string;
|
||||||
'1764273996.JPG',
|
count: number;
|
||||||
'_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'
|
|
||||||
];
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
images = imageFilenames
|
const params = buildFilterUrl();
|
||||||
.map((file) => {
|
const url = params ? `/api/portfolio/filters${params}` : '/api/portfolio/filters';
|
||||||
const base = file.replace(/\.JPG$/i, '');
|
const response = await fetch(url);
|
||||||
// Use a simple hash of the filename as timestamp for sorting
|
if (!response.ok) {
|
||||||
const timestamp = base.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
console.error('Filter fetch failed:', response.status, response.statusText);
|
||||||
return {
|
return;
|
||||||
full: `/portfolio/${file}`,
|
}
|
||||||
thumb: `/portfolio/${base}_thumb.jpg`,
|
|
||||||
timestamp
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.timestamp - a.timestamp);
|
|
||||||
|
|
||||||
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) {
|
} catch (e) {
|
||||||
error = true;
|
error = true;
|
||||||
loading = false;
|
|
||||||
console.error('Failed to load portfolio:', e);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasMore && !loadingMore && !loading) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '200px' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sentinelRef) {
|
||||||
|
observer.observe(sentinelRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
let showModal = false;
|
let featuredImage = $state<PortfolioImage | null>(null);
|
||||||
let selectedImage: string | null = null;
|
let showModal = $state(false);
|
||||||
let selectedThumb: string | null = null;
|
let selectedImage = $state<string | null>(null);
|
||||||
let currentIndex = 0;
|
let selectedThumb = $state<string | null>(null);
|
||||||
let imageLoading = false;
|
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) {
|
function openModal(img: string, thumb: string) {
|
||||||
currentIndex = images.findIndex((i) => i.full === img);
|
const idx = images.findIndex((i) => i.full === img);
|
||||||
selectedImage = img;
|
if (idx !== -1) {
|
||||||
selectedThumb = thumb;
|
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;
|
imageLoading = true;
|
||||||
showModal = 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() {
|
function navigateNext() {
|
||||||
@@ -153,6 +399,12 @@
|
|||||||
selectedImage = images[currentIndex].full;
|
selectedImage = images[currentIndex].full;
|
||||||
selectedThumb = images[currentIndex].thumb;
|
selectedThumb = images[currentIndex].thumb;
|
||||||
imageLoading = true;
|
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;
|
selectedImage = images[currentIndex].full;
|
||||||
selectedThumb = images[currentIndex].thumb;
|
selectedThumb = images[currentIndex].thumb;
|
||||||
imageLoading = true;
|
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';
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} onclick={handleWindowClick} onscroll={closeAllDropdowns} />
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="flex min-h-screen items-center justify-center p-4">
|
<div class="flex min-h-screen items-center justify-center p-4">
|
||||||
@@ -231,30 +500,154 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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)}
|
{#each images as img (img.full)}
|
||||||
<button
|
<button
|
||||||
class="relative aspect-square overflow-hidden rounded-xs transition-opacity hover:opacity-90 active:opacity-75"
|
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"
|
aria-label="View full size portfolio item"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src={img.thumb}
|
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"
|
class="h-full w-full object-cover opacity-0 transition-opacity duration-300"
|
||||||
on:load={handleImageLoad}
|
onload={handleImageLoad}
|
||||||
on:error={handleImageError}
|
onerror={handleImageError}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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}
|
||||||
|
|
||||||
{#if showModal}
|
{#if showModal}
|
||||||
<Dialog open={showModal} onOpenChange={(v) => (showModal = v)}>
|
<Dialog open={showModal} onOpenChange={(v) => (showModal = v)}>
|
||||||
<DialogOverlay class="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm" />
|
<DialogOverlay class="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm" />
|
||||||
<DialogContent
|
<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]"
|
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">
|
<div class="relative flex items-center justify-center">
|
||||||
@@ -274,14 +667,15 @@
|
|||||||
<img
|
<img
|
||||||
src={selectedImage ?? ''}
|
src={selectedImage ?? ''}
|
||||||
alt="Portfolio full size"
|
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="max-h-[95vh] max-w-[95vw] rounded-sm object-contain transition-opacity duration-300 sm:max-h-[90vh] sm:max-w-[90vw] {imageLoading
|
||||||
class:opacity-0={imageLoading}
|
? 'opacity-0'
|
||||||
on:load={handleFullImageLoad}
|
: ''}"
|
||||||
|
onload={handleFullImageLoad}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<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"
|
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"
|
aria-label="Close modal"
|
||||||
>
|
>
|
||||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -296,8 +690,9 @@
|
|||||||
|
|
||||||
{#if currentIndex > 0}
|
{#if currentIndex > 0}
|
||||||
<button
|
<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"
|
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"
|
aria-label="Previous image"
|
||||||
>
|
>
|
||||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -313,8 +708,9 @@
|
|||||||
|
|
||||||
{#if currentIndex < images.length - 1}
|
{#if currentIndex < images.length - 1}
|
||||||
<button
|
<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"
|
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"
|
aria-label="Next image"
|
||||||
>
|
>
|
||||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -372,20 +372,22 @@ CREATE INDEX idx_users_created_at ON users(created_at);
|
|||||||
CREATE INDEX idx_payments_created_at_status ON payments(created_at, status);
|
CREATE INDEX idx_payments_created_at_status ON payments(created_at, status);
|
||||||
|
|
||||||
create table images (
|
create table images (
|
||||||
id uuid primary key default gen_random_uuid(),
|
id uuid primary key default gen_random_uuid(),
|
||||||
r2_url text not null,
|
url text not null,
|
||||||
tag_names text[] not null default '{}', -- for searching and filtering
|
thumbnail_url text not null,
|
||||||
created_at timestamptz not null default now()
|
tag_names text[] not null default '{}',
|
||||||
|
created_at timestamptz not null default now()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- only for autocomlpete text
|
-- only for autocomplete text
|
||||||
create table tags (
|
create table tags (
|
||||||
id serial primary key,
|
id serial primary key,
|
||||||
name text not null unique
|
name text not null unique
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Indexes
|
-- Indexes
|
||||||
create index idx_images_tag_names on images using gin(tag_names);
|
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_images_created_at on images(created_at desc);
|
||||||
create index idx_tags_name_trgm on tags using gin (name gin_trgm_ops);
|
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"
|
log_success "PostgreSQL reset complete"
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
# --- 3b. Rustfs Reset (wipes data each run) ---
|
# --- 3b. Rustfs (don't wipe data, just restart) ---
|
||||||
log_step "Resetting Rustfs (S3-compatible storage)..."
|
log_step "Restarting Rustfs (S3-compatible storage)..."
|
||||||
docker compose down -v rustfs > /dev/null 2>&1
|
docker compose restart rustfs > /dev/null 2>&1
|
||||||
docker compose up rustfs -d > /dev/null 2>&1
|
log_success "Rustfs restarted"
|
||||||
log_success "Rustfs reset complete"
|
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
# --- 4. Tmux Session Setup ---
|
# --- 4. Tmux Session Setup ---
|
||||||
@@ -59,21 +58,42 @@ fi
|
|||||||
log_step "Starting tmux session '$SESSION_NAME'..."
|
log_step "Starting tmux session '$SESSION_NAME'..."
|
||||||
tmux new-session -d -s $SESSION_NAME -n "Workspace"
|
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
|
# Pane 0: Database
|
||||||
# Start interactive shell only. Stats will be shown after seeding.
|
# 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 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"
|
tmux select-pane -t $SESSION_NAME:0.0 -T "DB"
|
||||||
|
|
||||||
# Pane 1: Backend (Split Horizontally)
|
# Pane 1: Backend (Split Horizontally)
|
||||||
|
# Source .env to ensure all env vars are available to Go
|
||||||
tmux split-window -v -t $SESSION_NAME
|
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"
|
tmux select-pane -t $SESSION_NAME:0.1 -T "Backend"
|
||||||
|
|
||||||
# Pane 2: Frontend (Split Vertically from Backend)
|
# Pane 2: Frontend (Split Vertically from Backend)
|
||||||
tmux split-window -h -t $SESSION_NAME:0.1
|
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"
|
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
|
# Layout configuration
|
||||||
tmux select-layout -t $SESSION_NAME even-vertical
|
tmux select-layout -t $SESSION_NAME even-vertical
|
||||||
tmux select-pane -t $SESSION_NAME:0.0
|
tmux select-pane -t $SESSION_NAME:0.0
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
> **Last Updated:** January 2025
|
> **Last Updated:** February 2026
|
||||||
> **Status:** Work in Progress
|
> **Status:** Work in Progress
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
#### Not Yet Wired
|
#### Not Yet Wired
|
||||||
- [ ] Social auth (`handlers/auth/social.go` exists, not imported)
|
- [ ] Social auth (`handlers/auth/social.go` exists, not imported)
|
||||||
- [ ] Analytics (`handlers/admin/analytics.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)
|
- [ ] Guest user endpoint (`/api/users/guest` - needed for walk-in bookings)
|
||||||
|
|
||||||
#### Unit Tests & CI/CD
|
#### Unit Tests & CI/CD
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
- [x] Prices (`/prices`)
|
- [x] Prices (`/prices`)
|
||||||
- [x] Contact (`/contact`)
|
- [x] Contact (`/contact`)
|
||||||
- [x] Book (`/book`) - Full wizard with service selection, date/time, customer details
|
- [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] Today (`/today`) - Admin only, real-time schedule view
|
||||||
- [x] Account (`/account`)
|
- [x] Account (`/account`)
|
||||||
- [x] Login (`/login`)
|
- [x] Login (`/login`)
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
- [x] CalDAV ready
|
- [x] CalDAV ready
|
||||||
- [ ] Email/SMS reminders - not yet implemented
|
- [ ] Email/SMS reminders - not yet implemented
|
||||||
- [ ] Square payment - placeholder only
|
- [ ] 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 |
|
| DELETE | `/api/scheduling/exceptional-groups` | Delete exception group |
|
||||||
| PUT | `/api/scheduling/exceptional-applications` | Apply exceptions to dates |
|
| 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
|
## Database Schema
|
||||||
@@ -287,6 +306,8 @@ admin_notification_reason: pending_booking | cancelled_booking | rescheduled_boo
|
|||||||
| `payments` | Payment transactions |
|
| `payments` | Payment transactions |
|
||||||
| `business_settings` | Business configuration |
|
| `business_settings` | Business configuration |
|
||||||
| `admin_notifications` | Admin notification queue |
|
| `admin_notifications` | Admin notification queue |
|
||||||
|
| `images` | Portfolio gallery images with tags |
|
||||||
|
| `tags` | Image tag autocomplete |
|
||||||
| `user_notification_preferences` | User notification preferences (email/sms/push) |
|
| `user_notification_preferences` | User notification preferences (email/sms/push) |
|
||||||
|
|
||||||
### Key Functions
|
### 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 notification preferences** | [x] DB table ready, waiting on user notification system |
|
||||||
| **User notifications** | Notification system for regular users (booking confirmations, reminders) |
|
| **User notifications** | Notification system for regular users (booking confirmations, reminders) |
|
||||||
| **Remove debug logs** | `console.log` in BookingFlow.svelte:600 and BookingCreateModal.svelte:224 |
|
| **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 |
|
| **Loyalty display component** | Show stamps in account/bookings |
|
||||||
| **Email/SMS reminders** | Scheduled notification jobs |
|
| **Email/SMS reminders** | Scheduled notification jobs |
|
||||||
| **Prometheus metrics** | Monitoring integration |
|
| **Prometheus metrics** | Monitoring integration |
|
||||||
@@ -342,8 +362,7 @@ admin_notification_reason: pending_booking | cancelled_booking | rescheduled_boo
|
|||||||
| Task | Description |
|
| Task | Description |
|
||||||
| ----------------------- | ----------------------------------- |
|
| ----------------------- | ----------------------------------- |
|
||||||
| **Social auth** | Wire `handlers/auth/social.go` |
|
| **Social auth** | Wire `handlers/auth/social.go` |
|
||||||
| **Analytics** | Wire `handlers/admin/analytics.go` |
|
| **Analytics** | Wire `handlers/admin/analytics.go` |
|
||||||
| **Portfolio API** | Wire `handlers/portfolio/images.go` |
|
|
||||||
| **nginx config review** | Finalize production config |
|
| **nginx config review** | Finalize production config |
|
||||||
| **CI/CD pipeline** | Gitea Actions workflow |
|
| **CI/CD pipeline** | Gitea Actions workflow |
|
||||||
| And many more | |
|
| And many more | |
|
||||||
|
|||||||
Reference in New Issue
Block a user