From 9259de9393149bffeb61dcbd7089f6460f1fac4a Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Fri, 20 Feb 2026 00:32:09 +0000 Subject: [PATCH] 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 --- .env.example | 6 +- README.md | 3 +- backend/handlers/portfolio/images.go | 688 ++++++++++++++++-- backend/internal/s3/s3_dev.go | 70 +- backend/main.go | 16 + compose.yml | 12 +- frontend/src/app.css | 8 + .../account/UserBookingModal.svelte | 1 - .../admin/BookingCreateModal.svelte | 1 - .../lib/components/admin/ImageUpload.svelte | 276 +++++-- frontend/src/routes/api/[...path]/+server.ts | 18 +- frontend/src/routes/portfolio/+page.svelte | 668 +++++++++++++---- init-scripts/init-script.sql | 16 +- local-dev-2.sh | 34 +- obsidian/Crussell/Crussell Nails.md | 33 +- 15 files changed, 1560 insertions(+), 290 deletions(-) diff --git a/.env.example b/.env.example index 0655578..9b97359 100644 --- a/.env.example +++ b/.env.example @@ -16,9 +16,11 @@ JWT_SECRET_KEY="a-very-secret-key-that-should-be-in-env" # Dev: Uses local Rustfs container (see compose.yml) # Prod: Use Cloudflare R2 credentials S3_ENDPOINT=http://localhost:9000 -S3_ACCESS_KEY=minioadmin -S3_SECRET_KEY=minioadmin +S3_PUBLIC_URL=http://192.168.1.135:9000 +S3_ACCESS_KEY=rustfsadmin +S3_SECRET_KEY=rustfsadmin S3_BUCKET=crussell +AWS_REGION=eu-west-2 # Prod: Uncomment and fill these for R2 # R2_ENDPOINT=https://.r2.cloudflarestorage.com diff --git a/README.md b/README.md index 2d19eb2..e66e0ce 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ docker compose exec backend sh | VAT System | ✅ | ❌ | `get_vat_return_data()`, `calculate_vat()` functions exist | | User Referrals | ✅ | ❌ | `user_referrals` table, backend logic exists | | Token Refresh | ✅ | ✅ | POST /api/refresh-token, auto-refresh in auth store | +| Portfolio System | ✅ | ✅ | S3/R2 storage abstraction, tag-based filtering, category filters, admin upload, ?img= featured image param | ### ⚠️ Partially Complete @@ -185,7 +186,6 @@ docker compose exec backend sh |---------|------|-------| | Social Auth | `handlers/auth/social.go` | OAuth integration placeholder | | Analytics | `handlers/admin/analytics.go` | Statistics/dashboard endpoint | -| Portfolio Images | `handlers/portfolio/images.go` | Gallery management | ### 🚧 Critical TODOs @@ -203,7 +203,6 @@ docker compose exec backend sh |---------|-------------| | One-off Custom Services | Allow creating single-use services not in regular catalog | | One-off Exceptional Hours | Single-day overrides without creating a group | -| S3/R2 Image Hosting | Portfolio image storage with admin upload | --- diff --git a/backend/handlers/portfolio/images.go b/backend/handlers/portfolio/images.go index 8e2af16..a2c6ed2 100644 --- a/backend/handlers/portfolio/images.go +++ b/backend/handlers/portfolio/images.go @@ -1,48 +1,650 @@ package portfolio -// import ( -// "crussell/db" -// "net/http" -// ) +import ( + "bytes" + "context" + "crussell/db" + "crussell/internal/s3" + "crussell/mw" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "regexp" + "sort" + "strconv" + "strings" + "time" -// func GetImages(w http.ResponseWriter, r *http.Request) { -// rows, err := db.DB.Query(r.Context(), ` -// select id, r2_url -// from images -// where -// ($1::text[] is null or tag_names @> $1) -// and ($2::text is null or exists ( -// select 1 from unnest(tag_names) as tag -// where tag ilike '%' || $2 || '%' -// )) -// order by created_at desc -// limit $3 offset $4 -// `, -// semanticTags, -// searchTerm, -// limit, -// offset, -// ) -// } + "github.com/go-chi/chi/v5" +) -// func getAutoCompleteAdmin(w http.ResponseWriter, r *http.Request) { -// rows, err := db.DB.Query(r.Context(), ` -// select name -// from tags -// where name ilike '%' || $1 || '%' -// order by similarity(name, $1) desc -// limit 10 -// `, query) -// } +type Image struct { + ID string `json:"id"` + URL string `json:"url"` + ThumbnailURL string `json:"thumbnail_url"` + TagNames []string `json:"tag_names"` + CreatedAt time.Time `json:"created_at"` +} -// func getAutoCompleteUser(w http.ResponseWriter, r *http.Request) { -// rows, err := db.DB.Query(r.Context(), ` -// select name -// from tags -// where -// name not like '%:%' -// and name ilike '%' || $1 || '%' -// order by similarity(name, $1) desc -// limit 10 -// `, query) -// } +type Tag struct { + ID int `json:"id"` + Name string `json:"name"` +} + +func ListImages(w http.ResponseWriter, r *http.Request) { + tagFilter := r.URL.Query().Get("tag") + tagsFilter := r.URL.Query().Get("tags") + limit := 20 + offset := 0 + + if l := r.URL.Query().Get("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 { + limit = parsed + } + } + if o := r.URL.Query().Get("offset"); o != "" { + if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { + offset = parsed + } + } + + filterClauses := "" + filterArgs := []interface{}{} + + for key, values := range r.URL.Query() { + if len(values) == 0 || values[0] == "" { + continue + } + match, _ := regexp.Compile(`^filter\[(.+)\]$`) + if match != nil { + matches := match.FindStringSubmatch(key) + if len(matches) == 2 { + category := matches[1] + value := values[0] + filterClauses += fmt.Sprintf(" AND $%d = ANY(tag_names)", len(filterArgs)+1) + filterArgs = append(filterArgs, category+":"+value) + } + } + } + + var query string + var args []interface{} + + if tagsFilter != "" { + tagList := strings.Split(tagsFilter, ",") + cleanTags := make([]string, len(tagList)) + for i, t := range tagList { + cleanTags[i] = strings.TrimSpace(t) + } + conditions := make([]string, len(cleanTags)) + for i := range cleanTags { + conditions[i] = fmt.Sprintf("t ILIKE $%d", len(filterArgs)+i+1) + } + whereClause := strings.Join(conditions, " OR ") + similarityCalls := make([]string, len(cleanTags)) + for i := range cleanTags { + similarityCalls[i] = fmt.Sprintf("MAX(similarity(t, $%d))", len(filterArgs)+i+1) + } + similaritySum := strings.Join(similarityCalls, " + ") + + argOffset := len(filterArgs) + query = fmt.Sprintf(` + SELECT id, url, thumbnail_url, tag_names, created_at, + COUNT(t) as match_count, + %s as relevance + FROM images, unnest(tag_names) as t + WHERE %s%s + GROUP BY id, url, thumbnail_url, tag_names, created_at + ORDER BY match_count DESC, relevance DESC, created_at DESC + LIMIT $%d OFFSET $%d + `, similaritySum, whereClause, filterClauses, argOffset+len(cleanTags)+1, argOffset+len(cleanTags)+2) + + queryArgs := make([]interface{}, len(filterArgs)+len(cleanTags)+2) + copy(queryArgs, filterArgs) + for i, t := range cleanTags { + queryArgs[len(filterArgs)+i] = t + } + queryArgs[len(filterArgs)+len(cleanTags)] = limit + queryArgs[len(filterArgs)+len(cleanTags)+1] = offset + args = queryArgs + } else if tagFilter != "" { + argOffset := len(filterArgs) + searchPattern := "%" + tagFilter + "%" + query = fmt.Sprintf(` + SELECT id, url, thumbnail_url, tag_names, created_at, + CASE WHEN t = $1 THEN 2 ELSE 1 END as match_priority, + similarity(t, $1) as relevance + FROM images, unnest(tag_names) as t + WHERE t ILIKE '%%' || $1 || '%%%s' + ORDER BY match_priority DESC, relevance DESC, created_at DESC + LIMIT $%d OFFSET $%d + `, filterClauses, argOffset+2, argOffset+3) + + queryArgs := make([]interface{}, len(filterArgs)+3) + queryArgs[0] = searchPattern + copy(queryArgs[1:], filterArgs) + queryArgs[len(filterArgs)+1] = limit + queryArgs[len(filterArgs)+2] = offset + args = queryArgs + } else { + argOffset := len(filterArgs) + query = fmt.Sprintf(` + SELECT id, url, thumbnail_url, tag_names, created_at, 0 as match_count, 0.0 as relevance + FROM images + WHERE 1=1%s + ORDER BY created_at DESC + LIMIT $%d OFFSET $%d + `, filterClauses, argOffset+1, argOffset+2) + + queryArgs := make([]interface{}, len(filterArgs)+2) + copy(queryArgs, filterArgs) + queryArgs[len(filterArgs)] = limit + queryArgs[len(filterArgs)+1] = offset + args = queryArgs + } + + rows, err := db.DB.Query(r.Context(), query, args...) + if err != nil { + log.Printf("Failed to list images: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var images []Image + for rows.Next() { + var img Image + var matchCount int + var relevance float64 + if err := rows.Scan(&img.ID, &img.URL, &img.ThumbnailURL, (*[]string)(&img.TagNames), &img.CreatedAt, &matchCount, &relevance); err != nil { + log.Printf("Failed to scan image: %v", err) + continue + } + images = append(images, img) + } + + if images == nil { + images = []Image{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(images) +} + +func ListTags(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + + var query string + var args []interface{} + + if q != "" { + query = ` + SELECT id, name + FROM tags + WHERE name ILIKE $1 + ORDER BY name + LIMIT 20 + ` + args = []interface{}{q + "%"} + } else { + query = ` + SELECT id, name + FROM tags + ORDER BY name + LIMIT 20 + ` + } + + rows, err := db.DB.Query(r.Context(), query, args...) + if err != nil { + log.Printf("Failed to list tags: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var tags []Tag + for rows.Next() { + var t Tag + if err := rows.Scan(&t.ID, &t.Name); err != nil { + log.Printf("Failed to scan tag: %v", err) + continue + } + tags = append(tags, t) + } + + if tags == nil { + tags = []Tag{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tags) +} + +type FilterCategory struct { + Category string `json:"category"` + Values []FilterValue `json:"values"` +} + +type FilterValue struct { + Value string `json:"value"` + Count int `json:"count"` +} + +func ListFilters(w http.ResponseWriter, r *http.Request) { + tagFilter := r.URL.Query().Get("tag") + tagsFilter := r.URL.Query().Get("tags") + + // Get all filter params + selectedCategories := make(map[string]string) + for key, values := range r.URL.Query() { + if strings.HasPrefix(key, "filter[") && len(values) > 0 && values[0] != "" { + category := strings.Trim(key, "[]") + category = strings.TrimPrefix(category, "filter[") + selectedCategories[category] = values[0] + } + } + + // Build base query + baseQuery := "SELECT DISTINCT id FROM images WHERE 1=1" + args := []interface{}{} + argNum := 1 + + if tagFilter != "" { + searchPattern := "%" + tagFilter + "%" + baseQuery += fmt.Sprintf(" AND EXISTS (SELECT 1 FROM unnest(tag_names) AS t WHERE t ILIKE $%d)", argNum) + args = append(args, searchPattern) + argNum++ + } + if tagsFilter != "" { + tagList := strings.Split(tagsFilter, ",") + conditions := make([]string, len(tagList)) + for i := range tagList { + tagList[i] = "%" + strings.TrimSpace(tagList[i]) + "%" + conditions[i] = fmt.Sprintf("t ILIKE $%d", argNum) + args = append(args, tagList[i]) + argNum++ + } + baseQuery += " AND EXISTS (SELECT 1 FROM unnest(tag_names) AS t WHERE " + strings.Join(conditions, " OR ") + ")" + } + + // Build category filters for OTHER categories + otherFilters := make([]string, 0) + otherArgs := make([]interface{}, len(args)) + copy(otherArgs, args) + otherArgNum := argNum + for cat, val := range selectedCategories { + tagValue := cat + ":" + val + otherFilters = append(otherFilters, fmt.Sprintf("AND $%d = ANY(tag_names)", otherArgNum)) + otherArgs = append(otherArgs, tagValue) + otherArgNum++ + } + + // Build the filter count query + filterQuery := ` + SELECT + SPLIT_PART(t, ':', 1) as category, + SPLIT_PART(t, ':', 2) as value, + COUNT(*) as count + FROM (` + baseQuery + `) as img_ids + JOIN images ON images.id = img_ids.id + JOIN unnest(images.tag_names) as t ON true + WHERE t LIKE '%:%' + ` + + // If there are selected categories, we need two queries + if len(selectedCategories) > 0 { + // Query 1: Get all categories with filters applied (for unselected categories) + filterClause := "" + if len(otherFilters) > 0 { + filterClause = " " + strings.Join(otherFilters, " ") + } + + filteredQuery := filterQuery + filterClause + " GROUP BY category, value ORDER BY category, count DESC" + + rows, err := db.DB.Query(r.Context(), filteredQuery, otherArgs...) + if err != nil { + log.Printf("Failed to list filters: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + results := make(map[string]map[string]int) + for rows.Next() { + var category, value string + var count int + if err := rows.Scan(&category, &value, &count); err != nil { + continue + } + // Skip selected categories in this query + if _, isSelected := selectedCategories[category]; isSelected { + continue + } + if results[category] == nil { + results[category] = make(map[string]int) + } + results[category][value] = count + } + rows.Close() + + // Query 2: Get selected categories WITHOUT filters (show all options) + unfilteredQuery := filterQuery + " GROUP BY category, value ORDER BY category, count DESC" + rows, err = db.DB.Query(r.Context(), unfilteredQuery, args...) + if err != nil { + log.Printf("Failed to list unfiltered filters: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + for rows.Next() { + var category, value string + var count int + if err := rows.Scan(&category, &value, &count); err != nil { + continue + } + // Only include selected categories + if _, isSelected := selectedCategories[category]; !isSelected { + continue + } + if results[category] == nil { + results[category] = make(map[string]int) + } + results[category][value] = count + } + + // Convert to response + var filters []FilterCategory + for cat, values := range results { + var fv []FilterValue + for val, cnt := range values { + fv = append(fv, FilterValue{Value: val, Count: cnt}) + } + sort.Slice(fv, func(i, j int) bool { return fv[i].Count > fv[j].Count }) + filters = append(filters, FilterCategory{Category: cat, Values: fv}) + } + sort.Slice(filters, func(i, j int) bool { + sumI := 0 + for _, v := range filters[i].Values { + sumI += v.Count + } + sumJ := 0 + for _, v := range filters[j].Values { + sumJ += v.Count + } + return sumI > sumJ + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(filters) + return + } + + // No selected categories - simple query + finalQuery := filterQuery + " GROUP BY category, value ORDER BY category, count DESC" + + rows, err := db.DB.Query(r.Context(), finalQuery, args...) + if err != nil { + log.Printf("Failed to list filters: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var filters []FilterCategory + currentCategory := "" + var currentValues []FilterValue + + for rows.Next() { + var category, value string + var count int + if err := rows.Scan(&category, &value, &count); err != nil { + continue + } + if category != currentCategory { + if currentCategory != "" { + filters = append(filters, FilterCategory{Category: currentCategory, Values: currentValues}) + } + currentCategory = category + currentValues = []FilterValue{} + } + currentValues = append(currentValues, FilterValue{Value: value, Count: count}) + } + if currentCategory != "" { + filters = append(filters, FilterCategory{Category: currentCategory, Values: currentValues}) + } + + // Deduplicate categories - use a map to ensure each category appears only once + seenCategories := make(map[string]bool) + var uniqueFilters []FilterCategory + for _, f := range filters { + if !seenCategories[f.Category] { + seenCategories[f.Category] = true + uniqueFilters = append(uniqueFilters, f) + } + } + filters = uniqueFilters + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(filters) +} + +func UploadImage(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || userID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + adminRole, _ := r.Context().Value(mw.UserRoleKey).(string) + if adminRole != "admin" { + http.Error(w, "Admin access required", http.StatusForbidden) + return + } + + if s3.Client == nil { + log.Printf("S3 client not initialized") + http.Error(w, "Storage not configured", http.StatusInternalServerError) + return + } + + r.ParseMultipartForm(10 << 20) + + file, header, err := r.FormFile("file") + if err != nil { + log.Printf("Failed to get file: %v", err) + http.Error(w, "No file provided", http.StatusBadRequest) + return + } + defer file.Close() + + thumbFile, _, err := r.FormFile("thumbnail") + if err != nil { + log.Printf("Failed to get thumbnail: %v", err) + http.Error(w, "No thumbnail provided", http.StatusBadRequest) + return + } + defer thumbFile.Close() + + tagsStr := r.FormValue("tags") + var tags []string + if tagsStr != "" { + tags = strings.Split(tagsStr, ",") + for i := range tags { + tags[i] = strings.TrimSpace(tags[i]) + } + } + + // Use nanosecond timestamp for unique keys + timestamp := time.Now().UnixNano() + ext := ".jpg" + if idx := strings.LastIndex(header.Filename, "."); idx != -1 { + ext = strings.ToLower(header.Filename[idx:]) + } + + key := fmt.Sprintf("portfolio/%d%s", timestamp, ext) + thumbKey := fmt.Sprintf("portfolio/%d_thumb%s", timestamp, ext) + + fileBytes, err := io.ReadAll(file) + if err != nil { + log.Printf("Failed to read file: %v", err) + http.Error(w, "Failed to read file", http.StatusInternalServerError) + return + } + + thumbBytes, err := io.ReadAll(thumbFile) + if err != nil { + log.Printf("Failed to read thumbnail: %v", err) + http.Error(w, "Failed to read thumbnail", http.StatusInternalServerError) + return + } + + bucket := "crussell" + + if err := s3.Client.Upload(r.Context(), bucket, key, bytes.NewReader(fileBytes)); err != nil { + log.Printf("Failed to upload image to S3: %v", err) + http.Error(w, "Failed to upload image", http.StatusInternalServerError) + return + } + + if err := s3.Client.Upload(r.Context(), bucket, thumbKey, bytes.NewReader(thumbBytes)); err != nil { + log.Printf("Failed to upload thumbnail to S3: %v", err) + http.Error(w, "Failed to upload thumbnail", http.StatusInternalServerError) + return + } + + url, err := s3.Client.GetURL(r.Context(), bucket, key) + if err != nil { + log.Printf("Failed to get image URL: %v", err) + http.Error(w, "Failed to get URL", http.StatusInternalServerError) + return + } + + thumbURL, err := s3.Client.GetURL(r.Context(), bucket, thumbKey) + if err != nil { + log.Printf("Failed to get thumbnail URL: %v", err) + http.Error(w, "Failed to get thumbnail URL", http.StatusInternalServerError) + return + } + + var imgID string + err = db.DB.QueryRow(r.Context(), ` + INSERT INTO images (url, thumbnail_url, tag_names) + VALUES ($1, $2, $3) + RETURNING id + `, url, thumbURL, tags).Scan(&imgID) + + if err != nil { + log.Printf("Failed to insert image record: %v", err) + http.Error(w, "Failed to save image record", http.StatusInternalServerError) + return + } + + for _, tag := range tags { + if tag == "" { + continue + } + _, err = db.DB.Exec(r.Context(), ` + INSERT INTO tags (name) VALUES ($1) + ON CONFLICT (name) DO NOTHING + `, tag) + if err != nil { + log.Printf("Failed to insert tag %s: %v", tag, err) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Image{ + ID: imgID, + URL: url, + ThumbnailURL: thumbURL, + TagNames: tags, + CreatedAt: time.Now(), + }) +} + +func DeleteImage(w http.ResponseWriter, r *http.Request) { + userID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || userID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + adminRole, _ := r.Context().Value(mw.UserRoleKey).(string) + if adminRole != "admin" { + http.Error(w, "Admin access required", http.StatusForbidden) + return + } + + imageID := chi.URLParam(r, "id") + + var url, thumbURL string + err := db.DB.QueryRow(r.Context(), ` + SELECT url, thumbnail_url FROM images WHERE id = $1 + `, imageID).Scan(&url, thumbURL) + + if err != nil { + log.Printf("Failed to find image: %v", err) + http.Error(w, "Image not found", http.StatusNotFound) + return + } + + if s3.Client != nil { + key := extractKey(url) + thumbKey := extractKey(thumbURL) + + s3.Client.Delete(context.Background(), "crussell", key) + s3.Client.Delete(context.Background(), "crussell", thumbKey) + } + + _, err = db.DB.Exec(r.Context(), `DELETE FROM images WHERE id = $1`, imageID) + if err != nil { + log.Printf("Failed to delete image: %v", err) + http.Error(w, "Failed to delete image", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func extractKey(url string) string { + parts := strings.Split(url, "/") + if len(parts) > 0 { + return parts[len(parts)-1] + } + return url +} + +func GetImage(w http.ResponseWriter, r *http.Request) { + imageID := chi.URLParam(r, "id") + + var img Image + err := db.DB.QueryRow(r.Context(), ` + SELECT id, url, thumbnail_url, tag_names, created_at + FROM images + WHERE id = $1 + `, imageID).Scan(&img.ID, &img.URL, &img.ThumbnailURL, (*[]string)(&img.TagNames), &img.CreatedAt) + + if err != nil { + // Try to find by URL if UUID lookup failed + // This allows using timestamps/keys from S3 URLs + searchPattern := "%" + imageID + "%" + err = db.DB.QueryRow(r.Context(), ` + SELECT id, url, thumbnail_url, tag_names, created_at + FROM images + WHERE url LIKE $1 OR thumbnail_url LIKE $1 + LIMIT 1 + `, searchPattern).Scan(&img.ID, &img.URL, &img.ThumbnailURL, (*[]string)(&img.TagNames), &img.CreatedAt) + + if err != nil { + log.Printf("Failed to get image: %v", err) + http.Error(w, "Image not found", http.StatusNotFound) + return + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(img) +} diff --git a/backend/internal/s3/s3_dev.go b/backend/internal/s3/s3_dev.go index 7e97fa8..4e2fab9 100644 --- a/backend/internal/s3/s3_dev.go +++ b/backend/internal/s3/s3_dev.go @@ -32,29 +32,48 @@ type S3Client struct { } func Connect() error { - endpoint := os.Getenv("S3_ENDPOINT") + // Check for RUSTFS_* vars first (matching compose.yml), fall back to S3_* vars + endpoint := os.Getenv("RUSTFS_ENDPOINT") if endpoint == "" { + endpoint = os.Getenv("S3_ENDPOINT") + } + if endpoint == "" { + // Default: localhost for bare metal dev, use rustfs:9000 for docker endpoint = "http://localhost:9000" } - accessKey := os.Getenv("S3_ACCESS_KEY") + accessKey := os.Getenv("RUSTFS_ACCESS_KEY") + if accessKey == "" { + accessKey = os.Getenv("S3_ACCESS_KEY") + } if accessKey == "" { accessKey = "minioadmin" } - secretKey := os.Getenv("S3_SECRET_KEY") + secretKey := os.Getenv("RUSTFS_SECRET_KEY") + if secretKey == "" { + secretKey = os.Getenv("S3_SECRET_KEY") + } if secretKey == "" { secretKey = "minioadmin" } - bucket := os.Getenv("S3_BUCKET") + bucket := os.Getenv("RUSTFS_BUCKET") + if bucket == "" { + bucket = os.Getenv("S3_BUCKET") + } if bucket == "" { bucket = "crussell" } region := os.Getenv("AWS_REGION") if region == "" { - region = "us-east-1" + region = "eu-west-2" + } + + publicURL := os.Getenv("S3_PUBLIC_URL") + if publicURL == "" { + publicURL = endpoint } awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), @@ -75,7 +94,39 @@ func Connect() error { o.UsePathStyle = true }), bucket: bucket, - publicURL: endpoint, + publicURL: publicURL, + } + + // Create bucket if it doesn't exist + ctx := context.Background() + s3Client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(endpoint) + o.UsePathStyle = true + }) + _, err = s3Client.CreateBucket(ctx, &s3.CreateBucketInput{ + Bucket: aws.String(bucket), + }) + if err != nil { + log.Printf("Bucket creation: %v (may already exist)", err) + } + + // Set bucket policy for public read access + policy := fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [{ + "Sid": "PublicReadGetObject", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::%s/*" + }] + }`, bucket) + _, err = s3Client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ + Bucket: aws.String(bucket), + Policy: aws.String(policy), + }) + if err != nil { + log.Printf("Bucket policy: %v (may already exist)", err) } log.Printf("Connected to local S3 (Rustfs): bucket=%s, endpoint=%s", bucket, endpoint) @@ -84,9 +135,10 @@ func Connect() error { func (s *S3Client) Upload(ctx context.Context, bucket, key string, body io.Reader) error { _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(bucket), - Key: aws.String(key), - Body: body, + Bucket: aws.String(bucket), + Key: aws.String(key), + Body: body, + ContentType: aws.String("image/jpeg"), }) return err } diff --git a/backend/main.go b/backend/main.go index 061371b..c0471b6 100644 --- a/backend/main.go +++ b/backend/main.go @@ -19,6 +19,7 @@ import ( authHandlers "crussell/handlers/auth" "crussell/handlers/bookings" "crussell/handlers/notifications" + "crussell/handlers/portfolio" "crussell/handlers/scheduling" "crussell/handlers/services" "crussell/handlers/today" @@ -85,6 +86,21 @@ func main() { r.Post("/register", authHandlers.RegisterHandler) r.Post("/login", authHandlers.LoginHandler) + // --- Portfolio Routes (public read, admin write) --- + r.Route("/portfolio", func(r chi.Router) { + r.Get("/images", portfolio.ListImages) + r.Get("/tags", portfolio.ListTags) + r.Get("/filters", portfolio.ListFilters) + r.Get("/images/{id}", portfolio.GetImage) + + r.Group(func(r chi.Router) { + r.Use(mw.RequireAuth) + r.Use(mw.RequireAdmin) + r.Post("/images", portfolio.UploadImage) + r.Delete("/images/{id}", portfolio.DeleteImage) + }) + }) + // --- Scheduling Routes --- r.Route("/scheduling", func(r chi.Router) { // Public GET routes diff --git a/compose.yml b/compose.yml index 7ffecef..1928717 100644 --- a/compose.yml +++ b/compose.yml @@ -77,16 +77,18 @@ services: container_name: rustfs restart: always ports: - - "9001:9000" # Web console + - "9000:9000" + - "9001:9001" environment: - - RUSTFS_ADMIN_ACCESS_KEY=minioadmin - - RUSTFS_ADMIN_SECRET_KEY=minioadmin + - RUSTFS_ADMIN_ACCESS_KEY=rustfsadmin + - RUSTFS_ADMIN_SECRET_KEY=rustfsadmin + - RUSTFS_ACCESS_KEY=rustfsadmin + - RUSTFS_SECRET_KEY=rustfsadmin + - RUSTFS_BUCKET=crussell volumes: - rustfs_data:/data networks: - appnet - command: > - bash -c "rustfs server /data --console-address :9000 --address :9000 --axum-server" volumes: pgdata: diff --git a/frontend/src/app.css b/frontend/src/app.css index 03b650e..3593a20 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -127,4 +127,12 @@ [data-calendar] { z-index: 49 !important; +} + +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} +.scrollbar-hide::-webkit-scrollbar { + display: none; } \ No newline at end of file diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 29b2c5f..c5c4e38 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -4,7 +4,6 @@ import { toast } from 'svelte-sonner'; import * as Modal from '$lib/components/ui/dialog'; import { Button } from '$lib/components/ui/button'; - import { Separator } from '$lib/components/ui/separator'; interface Props { open: boolean; diff --git a/frontend/src/lib/components/admin/BookingCreateModal.svelte b/frontend/src/lib/components/admin/BookingCreateModal.svelte index 9b91d3d..9ce5b35 100644 --- a/frontend/src/lib/components/admin/BookingCreateModal.svelte +++ b/frontend/src/lib/components/admin/BookingCreateModal.svelte @@ -253,7 +253,6 @@ users = (data.users || []).filter( (user: { account_role: string }) => !excludedRoles.includes(user.account_role) ); - console.log(users); } } catch (err) { console.error('Failed to fetch users', err); diff --git a/frontend/src/lib/components/admin/ImageUpload.svelte b/frontend/src/lib/components/admin/ImageUpload.svelte index 118c227..00b18dc 100644 --- a/frontend/src/lib/components/admin/ImageUpload.svelte +++ b/frontend/src/lib/components/admin/ImageUpload.svelte @@ -2,6 +2,8 @@ import { Button } from '$lib/components/ui/button'; import * as Card from '$lib/components/ui/card'; import FileDropZone from '$lib/components/ui/file-drop-zone.svelte'; + import { authStore } from '$lib/stores/auth.svelte'; + import * as AlertDialog from '$lib/components/ui/alert-dialog'; // =============== Image Upload =============== let uploading = $state(false); @@ -80,18 +82,21 @@ }); } - /** Create a 250×250 thumbnail (square, center-cropped). */ + /** Create a 250×250 thumbnail. First scale down so short side is 250px, then center-crop to 250×250 square. */ function createThumbnail(blob: Blob): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { + const targetShortSide = 250; const thumbSize = 250; - const { width, height } = img; + let { width, height } = img; - // Scale up *or* down so that the image covers 250×250 - const scale = Math.max(thumbSize / width, thumbSize / height); - const scaledW = Math.round(width * scale); - const scaledH = Math.round(height * scale); + const shortSide = Math.min(width, height); + if (shortSide > targetShortSide) { + const scale = targetShortSide / shortSide; + width = Math.round(width * scale); + height = Math.round(height * scale); + } const canvas = document.createElement('canvas'); canvas.width = thumbSize; @@ -99,18 +104,12 @@ const ctx = canvas.getContext('2d'); if (!ctx) return reject(new Error('2D context not available')); - // Draw the scaled image, then crop the center 250×250 - ctx.drawImage( - img, - (scaledW - thumbSize) / -2, // offset to center - (scaledH - thumbSize) / -2, - scaledW, - scaledH, - 0, - 0, - thumbSize, - thumbSize - ); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + + const destX = (thumbSize - width) / 2; + const destY = (thumbSize - height) / 2; + ctx.drawImage(img, 0, 0, img.width, img.height, destX, destY, width, height); canvas.toBlob( (blob) => { @@ -126,32 +125,42 @@ }); } - const knownTags = [ - 'portfolio', - 'gel', - 'acrylic', - 'french', - 'ombre', - 'summer', - 'wedding', - 'holiday', - 'pink', - 'red', - 'style:french', - 'style:minimal', - 'colour:red', - 'colour:pink', - 'season:summer' - ]; + let availableTags = $state([]); + let loadingTags = $state(true); + let isMobile = $state(false); + + async function fetchTags() { + try { + const response = await fetch('/api/portfolio/tags'); + if (response.ok) { + const data = await response.json(); + availableTags = data.map((t: { name: string }) => t.name); + } + } catch (e) { + console.error('Failed to fetch tags:', e); + } finally { + loadingTags = false; + } + } + + // Check for mobile on mount + if (typeof window !== 'undefined') { + isMobile = window.matchMedia('(pointer: coarse)').matches; + } + + fetchTags(); let tags = $state([]); let input = $state(''); + let selectedSuggestionIndex = $state(-1); + let inputRef = $state(undefined); + let showConfirmUploadAlert = $state(false); const suggestions = $derived.by(() => { const q = input.trim().toLowerCase(); if (!q) return []; - return knownTags + return availableTags .map((t) => t.toLowerCase()) .filter((t) => t.startsWith(q) && !tags.includes(t)) .slice(0, 6); @@ -164,6 +173,16 @@ addTag(value); input = ''; } + selectedSuggestionIndex = -1; + } + + function handlePaste(e: ClipboardEvent) { + const value = e.clipboardData?.getData('text') || ''; + if (value.includes(',')) { + e.preventDefault(); + addTag(value); + input = ''; + } } function isSemantic(tag: string) { @@ -172,16 +191,93 @@ function addTag(raw: string) { raw.split(',').forEach((p) => { - const t = p.trim().toLowerCase(); - if (t && !tags.includes(t)) tags = [...tags, t]; + let t = p.trim().toLowerCase(); + if (!t) return; + t = t.replace(/^colour:/, 'color:'); + if (!tags.includes(t)) tags = [...tags, t]; }); } function handleKey(e: KeyboardEvent) { - if (e.key === 'Enter') { + // Prevent tab from moving focus away from this input + if (e.key === 'Tab') { e.preventDefault(); - addTag(input); - input = ''; + // Allow arrow key navigation when tab is pressed + if (e.shiftKey) { + // Shift+Tab - go to previous suggestion (wrap to end) + if (suggestions.length > 0) { + if (selectedSuggestionIndex <= 0) { + selectedSuggestionIndex = suggestions.length - 1; + } else { + selectedSuggestionIndex = selectedSuggestionIndex - 1; + } + } + } else { + // Tab - go to next suggestion (wrap to start) + if (suggestions.length > 0) { + if (selectedSuggestionIndex >= suggestions.length - 1) { + selectedSuggestionIndex = 0; + } else { + selectedSuggestionIndex = selectedSuggestionIndex + 1; + } + } + } + return; + } + + // Handle mobile keyboard action keys + const isActionKey = ['Enter', 'Done', 'Go'].includes(e.key); + + if (isActionKey) { + e.preventDefault(); + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (suggestions.length > 0) { + // Wrap to start if at end + if (selectedSuggestionIndex >= suggestions.length - 1) { + selectedSuggestionIndex = 0; + } else { + selectedSuggestionIndex = Math.min(selectedSuggestionIndex + 1, suggestions.length - 1); + } + } + return; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + if (suggestions.length > 0) { + // Wrap to end if at start + if (selectedSuggestionIndex <= 0) { + selectedSuggestionIndex = suggestions.length - 1; + } else { + selectedSuggestionIndex = Math.max(selectedSuggestionIndex - 1, -1); + } + } + return; + } + + if (e.key === 'Enter' || e.key === 'Done' || e.key === 'Go') { + // If there's a highlighted suggestion, select it + if (selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]) { + selectSuggestion(suggestions[selectedSuggestionIndex]); + return; + } + + // If input has text, add it as tag + if (input.trim()) { + addTag(input); + input = ''; + return; + } + + // If input is empty but we have tags, show confirmation to upload + if (tags.length > 0) { + showConfirmUploadAlert = true; + return; + } + return; } @@ -193,6 +289,7 @@ function selectSuggestion(tag: string) { addTag(tag); input = ''; + selectedSuggestionIndex = -1; } function removeTag(tag: string) { @@ -201,6 +298,12 @@ /** Core upload function – now processes the images before sending. */ async function uploadOneOrMany() { + // Add any pending input as tag before uploading + if (input.trim()) { + addTag(input); + input = ''; + } + if (!uploadFiles.length) return; uploading = true; uploadResults = []; @@ -226,21 +329,33 @@ const thumbName = `${ts}_thumb.jpg`; /* -------- 4. Attach to FormData -------------------------------- */ - fd.append('file', resizedBlob, baseName); // this will be the "original" - fd.append('file', thumbBlob, thumbName); // the thumbnail + fd.append('file', resizedBlob, baseName); + fd.append('thumbnail', thumbBlob, thumbName); - /* -------- 5. Mock the API call --------------------------------- */ - await new Promise((r) => setTimeout(r, 500)); // Simulate network delay - if (file.name.toLowerCase().includes('fail')) { + if (tags.length > 0) { + fd.append('tags', tags.join(',')); + } + + /* -------- 5. Call the API ------------------------------------- */ + const response = await fetch('/api/portfolio/images', { + method: 'POST', + headers: { + Authorization: `Bearer ${authStore.currentToken}` + }, + body: fd + }); + + if (!response.ok) { + const errText = await response.text(); uploadResults.push({ name: file.name, - error: 'Mocked API error' + error: errText || `Server error: ${response.status}` }); } else { - // In a real app you would `await fetch('/api/upload', {method:'POST', body:fd})` + const result = await response.json(); uploadResults.push({ name: file.name, - url: `/images/${baseName}` // pretend this is the returned URL + url: result.url }); } } catch (err: unknown) { @@ -312,7 +427,12 @@ ? 'bg-red-100 text-red-800' : 'bg-emerald-100 text-emerald-800'}" > - {result.name}: {result.error ? `Failed: ${result.error}` : `Success: ${result.url}`} + {result.name}: {result.error ? `Failed: ${result.error}` : `Success: `}{result.url} {/each} @@ -324,25 +444,22 @@
{#each tags as tag (tag)} {tag}
{#if input.length && suggestions.length} -
- {#each suggestions as s (s)} +
+ {#each suggestions as s, i (s)} + {@const isHighlighted = isMobile ? i === 0 : i === selectedSuggestionIndex}
{ - e.preventDefault(); - selectSuggestion(s); - }} + class="cursor-pointer px-3 py-3 text-sm touch-manipulation {isHighlighted + ? 'bg-primary/10 text-primary font-medium' + : 'hover:bg-gray-100'}" + onclick={() => selectSuggestion(s)} > {s}
@@ -390,3 +515,20 @@
+ + + + + Upload with {tags.length} tag{tags.length === 1 ? '' : 's'}? + + You have {tags.length} tag{tags.length === 1 ? '' : 's'} selected: + {tags.join(', ')}. + Ready to upload {uploadFiles.length} file{uploadFiles.length === 1 ? '' : 's'}? + + + + Cancel + Upload + + + diff --git a/frontend/src/routes/api/[...path]/+server.ts b/frontend/src/routes/api/[...path]/+server.ts index 703c085..ba023dd 100644 --- a/frontend/src/routes/api/[...path]/+server.ts +++ b/frontend/src/routes/api/[...path]/+server.ts @@ -14,13 +14,25 @@ async function proxyRequest(request: Request, path: string) { try { const headers = new Headers(request.headers); headers.delete('host'); + headers.delete('content-length'); + + let body: BodyInit | undefined; + let fetchOptions: RequestInit = {}; + if (!['GET', 'HEAD'].includes(request.method)) { + const contentType = request.headers.get('content-type') || ''; + if (contentType.includes('multipart/form-data')) { + body = request.body; + fetchOptions.duplex = 'half'; + } else { + body = await request.text(); + } + } const backendRes = await fetch(url, { method: request.method, headers, - body: ['GET', 'HEAD'].includes(request.method) - ? undefined - : await request.text() + body, + ...fetchOptions }); // Forward everything transparently diff --git a/frontend/src/routes/portfolio/+page.svelte b/frontend/src/routes/portfolio/+page.svelte index 7cec550..92f1c63 100644 --- a/frontend/src/routes/portfolio/+page.svelte +++ b/frontend/src/routes/portfolio/+page.svelte @@ -1,150 +1,396 @@ - + {#if loading}
@@ -231,30 +500,154 @@
{:else} -
+
+
+
+
+ {#if filterCategories.length > 0} + {@const hasActiveFilters = Object.values(selectedFilters).some((v) => v && v !== '')} + {#if hasActiveFilters || selectedTag || selectedTags.length > 0} + + {/if} + {#each filterCategories as filter (filter.category)} + {@const selectedValue = selectedFilters[filter.category]} + {@const hasActiveFilter = selectedValue && selectedValue !== ''} +
+ +
+ {/each} + {/if} +
+ {#each filterCategories as filter (filter.category)} + {@const selectedValue = selectedFilters[filter.category]} + {#if openDropdowns[filter.category]} +
+ + {#each filter.values as value (value.value)} + + {/each} +
+ {/if} + {/each} +
+ +
+ { + if ((e as KeyboardEvent).key === 'Enter') applySearch(); + }} + /> + +
+
+
+ +
{#each images as img (img.full)} {/each}
+ +
+ {#if loadingMore} +
+
+
+ {:else if !hasMore && images.length > 0} +

No more images to load

+ {/if} +
{/if} {#if showModal} (showModal = v)}>
@@ -274,14 +667,15 @@ Portfolio full size