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:
2026-02-20 00:32:09 +00:00
parent dfd552b02f
commit 9259de9393
15 changed files with 1560 additions and 290 deletions
+4 -2
View File
@@ -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
+1 -2
View File
@@ -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 |
--- ---
+645 -43
View File
@@ -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)
}
+61 -9
View File
@@ -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
} }
+16
View File
@@ -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
View File
@@ -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:
+8
View File
@@ -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>
+15 -3
View File
@@ -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
+532 -136
View File
@@ -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">
+9 -7
View File
@@ -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
View File
@@ -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
+26 -7
View File
@@ -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 | |