From 3420b45a6a6763efa3dce2a421c1c61b4182ce2f Mon Sep 17 00:00:00 2001 From: shen Date: Sun, 31 May 2026 17:06:52 -0400 Subject: [PATCH 01/15] wip image editor backend --- .../000004_add_image_lineage_indexes.down.sql | 4 + .../000004_add_image_lineage_indexes.up.sql | 4 + domainmodels/image.go | 1 + editing/changeset.go | 33 ++ editing/changeset_test.go | 66 +++ editing/fetch.go | 80 +++ editing/watermark.go | 184 ++++++ editing/watermark_test.go | 189 +++++++ gqlgen.yml | 4 + graph/generated.go | 535 ++++++++++++++++++ graph/graph_test.go | 1 + graph/images.resolvers.go | 153 +++++ graph/images.resolvers_test.go | 216 +++++++ graph/model/image.go | 6 + graph/model/models_gen.go | 79 +++ graph/resolver.go | 1 + graph/schema/images.graphqls | 29 + httpserver/make_server.go | 1 + httpserver/utils.go | 2 + image/repo.go | 16 +- 20 files changed, 1603 insertions(+), 1 deletion(-) create mode 100644 db/migrations/000004_add_image_lineage_indexes.down.sql create mode 100644 db/migrations/000004_add_image_lineage_indexes.up.sql create mode 100644 editing/changeset.go create mode 100644 editing/changeset_test.go create mode 100644 editing/fetch.go create mode 100644 editing/watermark.go create mode 100644 editing/watermark_test.go diff --git a/db/migrations/000004_add_image_lineage_indexes.down.sql b/db/migrations/000004_add_image_lineage_indexes.down.sql new file mode 100644 index 0000000..fd48db3 --- /dev/null +++ b/db/migrations/000004_add_image_lineage_indexes.down.sql @@ -0,0 +1,4 @@ +BEGIN; +DROP INDEX IF EXISTS image_table_root_id_idx; +DROP INDEX IF EXISTS image_table_parent_id_idx; +COMMIT; diff --git a/db/migrations/000004_add_image_lineage_indexes.up.sql b/db/migrations/000004_add_image_lineage_indexes.up.sql new file mode 100644 index 0000000..60ad6c0 --- /dev/null +++ b/db/migrations/000004_add_image_lineage_indexes.up.sql @@ -0,0 +1,4 @@ +BEGIN; +CREATE INDEX image_table_root_id_idx ON image_table(root_id); +CREATE INDEX image_table_parent_id_idx ON image_table(parent_id); +COMMIT; diff --git a/domainmodels/image.go b/domainmodels/image.go index aeec1b6..9782a09 100644 --- a/domainmodels/image.go +++ b/domainmodels/image.go @@ -33,6 +33,7 @@ type Image struct { Identifier string RootId string ParentId string + Changes string UploaderIP string MIMEType string NominalWidth int32 diff --git a/editing/changeset.go b/editing/changeset.go new file mode 100644 index 0000000..39f1f98 --- /dev/null +++ b/editing/changeset.go @@ -0,0 +1,33 @@ +package editing + +import ( + "encoding/json" + "fmt" +) + +type ChangeSet struct { + Type string `json:"type"` + Params json.RawMessage `json:"params"` +} + +// FetchImageFunc retrieves image bytes by image ID. +type FetchImageFunc func(id string) ([]byte, error) + +// Editor applies a ChangeSet to base image bytes, producing new image bytes. +type Editor interface { + Apply(base []byte, cs ChangeSet, fetchImage FetchImageFunc) ([]byte, string, error) +} + +var registry = map[string]Editor{} + +func Register(changeType string, editor Editor) { + registry[changeType] = editor +} + +func GetEditor(changeType string) (Editor, error) { + e, ok := registry[changeType] + if !ok { + return nil, fmt.Errorf("unknown change type: %s", changeType) + } + return e, nil +} diff --git a/editing/changeset_test.go b/editing/changeset_test.go new file mode 100644 index 0000000..2073d3e --- /dev/null +++ b/editing/changeset_test.go @@ -0,0 +1,66 @@ +package editing + +import ( + "encoding/json" + "testing" +) + +func TestChangeSetSerialize(t *testing.T) { + params := WatermarkParams{ + OverlayImageID: "abc-123", + Position: WatermarkPosition{X: 0.9, Y: 0.9}, + Anchor: AnchorBottomRight, + Opacity: 0.5, + Scale: 0.15, + } + paramsJSON, err := json.Marshal(params) + if err != nil { + t.Fatal(err) + } + + cs := ChangeSet{ + Type: "watermark", + Params: paramsJSON, + } + + data, err := json.Marshal(cs) + if err != nil { + t.Fatal(err) + } + + var decoded ChangeSet + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Type != "watermark" { + t.Fatalf("expected type 'watermark', got '%s'", decoded.Type) + } + + var decodedParams WatermarkParams + if err := json.Unmarshal(decoded.Params, &decodedParams); err != nil { + t.Fatal(err) + } + if decodedParams.OverlayImageID != "abc-123" { + t.Fatalf("expected overlay_image_id 'abc-123', got '%s'", decodedParams.OverlayImageID) + } + if decodedParams.Opacity != 0.5 { + t.Fatalf("expected opacity 0.5, got %f", decodedParams.Opacity) + } +} + +func TestRegistryGetEditor(t *testing.T) { + editor, err := GetEditor("watermark") + if err != nil { + t.Fatal(err) + } + if editor == nil { + t.Fatal("expected non-nil editor for 'watermark'") + } +} + +func TestRegistryGetUnknownEditor(t *testing.T) { + _, err := GetEditor("nonexistent") + if err == nil { + t.Fatal("expected error for unknown editor type") + } +} diff --git a/editing/fetch.go b/editing/fetch.go new file mode 100644 index 0000000..208ebea --- /dev/null +++ b/editing/fetch.go @@ -0,0 +1,80 @@ +package editing + +import ( + "fmt" + "io" + "sort" + + dm "github.com/ericls/imgdd/domainmodels" + "github.com/ericls/imgdd/storage" +) + +// NewFetchImageFunc creates a FetchImageFunc that reads image bytes from storage. +func NewFetchImageFunc( + storedImageRepo storage.StoredImageRepo, + storageDefRepo storage.StorageDefRepo, +) FetchImageFunc { + return func(imageId string) ([]byte, error) { + storedImages, err := storedImageRepo.GetStoredImagesByImageId(imageId) + if err != nil { + return nil, fmt.Errorf("failed to get stored images for %s: %w", imageId, err) + } + if len(storedImages) == 0 { + return nil, fmt.Errorf("no stored images found for %s", imageId) + } + + // Collect storage definition IDs + defIds := make([]string, 0, len(storedImages)) + for _, si := range storedImages { + defIds = append(defIds, si.StorageDefinitionId) + } + defs, err := storageDefRepo.GetStorageDefinitionsByIds(defIds) + if err != nil { + return nil, fmt.Errorf("failed to get storage definitions for %s: %w", imageId, err) + } + + // Build lookup and filter enabled + defMap := make(map[string]*dm.StorageDefinition) + for _, d := range defs { + if d != nil && d.IsEnabled { + defMap[d.Id] = d + } + } + + type candidate struct { + si *dm.StoredImage + def *dm.StorageDefinition + } + var candidates []candidate + for _, si := range storedImages { + if d, ok := defMap[si.StorageDefinitionId]; ok { + candidates = append(candidates, candidate{si, d}) + } + } + if len(candidates) == 0 { + return nil, fmt.Errorf("no enabled storage backends for image %s", imageId) + } + + sort.SliceStable(candidates, func(i, j int) bool { + return candidates[i].def.Priority < candidates[j].def.Priority + }) + + best := candidates[0] + storageInstance, err := storage.GetStorage(best.def) + if err != nil { + return nil, fmt.Errorf("failed to create storage instance: %w", err) + } + + reader := storageInstance.GetReader(best.si.FileIdentifier) + if reader == nil { + return nil, fmt.Errorf("failed to get reader for file %s", best.si.FileIdentifier) + } + defer reader.Close() + + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read image data: %w", err) + } + return data, nil + } +} diff --git a/editing/watermark.go b/editing/watermark.go new file mode 100644 index 0000000..7b66430 --- /dev/null +++ b/editing/watermark.go @@ -0,0 +1,184 @@ +package editing + +import ( + "bytes" + "encoding/json" + "fmt" + "image" + "image/color" + "image/draw" + "image/gif" + "image/jpeg" + "image/png" + "math" + + _ "golang.org/x/image/bmp" + xdraw "golang.org/x/image/draw" + _ "golang.org/x/image/webp" + + "github.com/ericls/imgdd/utils" +) + +func init() { + Register("watermark", &WatermarkEditor{}) +} + +type Anchor string + +const ( + AnchorTopLeft Anchor = "top_left" + AnchorTopRight Anchor = "top_right" + AnchorBottomLeft Anchor = "bottom_left" + AnchorBottomRight Anchor = "bottom_right" + AnchorCenter Anchor = "center" +) + +type WatermarkPosition struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type WatermarkParams struct { + OverlayImageID string `json:"overlay_image_id"` + Position WatermarkPosition `json:"position"` + Anchor Anchor `json:"anchor"` + Opacity float64 `json:"opacity"` + Scale float64 `json:"scale"` +} + +type WatermarkEditor struct{} + +func (e *WatermarkEditor) Apply(baseBytes []byte, cs ChangeSet, fetchImage FetchImageFunc) ([]byte, string, error) { + var params WatermarkParams + if err := json.Unmarshal(cs.Params, ¶ms); err != nil { + return nil, "", fmt.Errorf("invalid watermark params: %w", err) + } + + if params.Opacity < 0 || params.Opacity > 1 { + return nil, "", fmt.Errorf("opacity must be between 0 and 1") + } + if params.Scale <= 0 || params.Scale > 1 { + return nil, "", fmt.Errorf("scale must be between 0 (exclusive) and 1") + } + + // Detect base image MIME type + baseMime := utils.DetectMIMEType(&baseBytes) + + // Decode base image + baseImg, _, err := image.Decode(bytes.NewReader(baseBytes)) + if err != nil { + return nil, "", fmt.Errorf("failed to decode base image: %w", err) + } + + // Fetch and decode overlay image + overlayBytes, err := fetchImage(params.OverlayImageID) + if err != nil { + return nil, "", fmt.Errorf("failed to fetch overlay image: %w", err) + } + overlayImg, _, err := image.Decode(bytes.NewReader(overlayBytes)) + if err != nil { + return nil, "", fmt.Errorf("failed to decode overlay image: %w", err) + } + + // Scale overlay relative to base image's shorter dimension + baseBounds := baseImg.Bounds() + shortSide := baseBounds.Dx() + if baseBounds.Dy() < shortSide { + shortSide = baseBounds.Dy() + } + targetSize := int(math.Round(float64(shortSide) * params.Scale)) + if targetSize < 1 { + targetSize = 1 + } + + overlayBounds := overlayImg.Bounds() + overlayAspect := float64(overlayBounds.Dx()) / float64(overlayBounds.Dy()) + var scaledW, scaledH int + if overlayAspect >= 1 { + scaledW = targetSize + scaledH = int(math.Round(float64(targetSize) / overlayAspect)) + } else { + scaledH = targetSize + scaledW = int(math.Round(float64(targetSize) * overlayAspect)) + } + if scaledW < 1 { + scaledW = 1 + } + if scaledH < 1 { + scaledH = 1 + } + + // Scale overlay + scaledOverlay := image.NewRGBA(image.Rect(0, 0, scaledW, scaledH)) + xdraw.CatmullRom.Scale(scaledOverlay, scaledOverlay.Bounds(), overlayImg, overlayBounds, xdraw.Over, nil) + + // Apply opacity + if params.Opacity < 1.0 { + applyOpacity(scaledOverlay, params.Opacity) + } + + // Calculate position + px := int(math.Round(params.Position.X * float64(baseBounds.Dx()))) + py := int(math.Round(params.Position.Y * float64(baseBounds.Dy()))) + offsetX, offsetY := anchorOffset(params.Anchor, scaledW, scaledH) + drawX := px + offsetX + drawY := py + offsetY + + // Composite + result := image.NewRGBA(baseBounds) + draw.Draw(result, baseBounds, baseImg, baseBounds.Min, draw.Src) + draw.Draw(result, image.Rect(drawX, drawY, drawX+scaledW, drawY+scaledH), scaledOverlay, image.Point{}, draw.Over) + + // Encode in the same format as the base image + var buf bytes.Buffer + switch baseMime { + case "image/png": + err = png.Encode(&buf, result) + case "image/jpeg": + err = jpeg.Encode(&buf, result, &jpeg.Options{Quality: 95}) + case "image/gif": + err = gif.Encode(&buf, result, nil) + default: + // Fall back to PNG for formats we can't encode (webp, bmp) + err = png.Encode(&buf, result) + baseMime = "image/png" + } + if err != nil { + return nil, "", fmt.Errorf("failed to encode result: %w", err) + } + return buf.Bytes(), baseMime, nil +} + +func applyOpacity(img *image.RGBA, opacity float64) { + bounds := img.Bounds() + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + r, g, b, a := img.At(x, y).RGBA() + newA := float64(a>>8) * opacity + // Premultiply RGB by the opacity factor for correct alpha compositing + img.SetRGBA(x, y, color.RGBA{ + R: uint8(float64(r>>8) * opacity), + G: uint8(float64(g>>8) * opacity), + B: uint8(float64(b>>8) * opacity), + A: uint8(newA), + }) + } + } +} + +func anchorOffset(anchor Anchor, w, h int) (int, int) { + switch anchor { + case AnchorTopLeft: + return 0, 0 + case AnchorTopRight: + return -w, 0 + case AnchorBottomLeft: + return 0, -h + case AnchorBottomRight: + return -w, -h + case AnchorCenter: + return -w / 2, -h / 2 + default: + return 0, 0 + } +} diff --git a/editing/watermark_test.go b/editing/watermark_test.go new file mode 100644 index 0000000..5066f32 --- /dev/null +++ b/editing/watermark_test.go @@ -0,0 +1,189 @@ +package editing + +import ( + "bytes" + "encoding/json" + "image" + "image/color" + "image/png" + "testing" +) + +// makeTestPNG creates a solid-color PNG image of the given dimensions. +func makeTestPNG(w, h int, c color.Color) []byte { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, c) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + panic(err) + } + return buf.Bytes() +} + +func makeParams(overlayID string, x, y float64, anchor Anchor, opacity, scale float64) ChangeSet { + params := WatermarkParams{ + OverlayImageID: overlayID, + Position: WatermarkPosition{X: x, Y: y}, + Anchor: anchor, + Opacity: opacity, + Scale: scale, + } + paramsJSON, _ := json.Marshal(params) + return ChangeSet{Type: "watermark", Params: paramsJSON} +} + +func TestWatermarkBasic(t *testing.T) { + base := makeTestPNG(200, 200, color.RGBA{255, 0, 0, 255}) + overlay := makeTestPNG(50, 50, color.RGBA{0, 0, 255, 255}) + + cs := makeParams("overlay-1", 0.5, 0.5, AnchorCenter, 1.0, 0.25) + + editor := &WatermarkEditor{} + result, mime, err := editor.Apply(base, cs, func(id string) ([]byte, error) { + if id == "overlay-1" { + return overlay, nil + } + return nil, nil + }) + if err != nil { + t.Fatal(err) + } + if mime != "image/png" { + t.Fatalf("expected image/png, got %s", mime) + } + if len(result) == 0 { + t.Fatal("expected non-empty result") + } + + // Decode result and verify dimensions match base + resultImg, err := png.Decode(bytes.NewReader(result)) + if err != nil { + t.Fatal(err) + } + bounds := resultImg.Bounds() + if bounds.Dx() != 200 || bounds.Dy() != 200 { + t.Fatalf("expected 200x200, got %dx%d", bounds.Dx(), bounds.Dy()) + } + + // The center pixel should not be pure red anymore (overlay is blue) + r, g, b, _ := resultImg.At(100, 100).RGBA() + if r == 0xffff && g == 0 && b == 0 { + t.Fatal("expected center pixel to be modified by overlay") + } +} + +func TestWatermarkZeroOpacity(t *testing.T) { + base := makeTestPNG(100, 100, color.RGBA{255, 0, 0, 255}) + overlay := makeTestPNG(50, 50, color.RGBA{0, 0, 255, 255}) + + cs := makeParams("overlay-1", 0.5, 0.5, AnchorCenter, 0.0, 0.5) + + editor := &WatermarkEditor{} + result, _, err := editor.Apply(base, cs, func(id string) ([]byte, error) { + return overlay, nil + }) + if err != nil { + t.Fatal(err) + } + + // With zero opacity, the image should be unchanged — center should still be red + resultImg, _ := png.Decode(bytes.NewReader(result)) + r, g, b, _ := resultImg.At(50, 50).RGBA() + if r != 0xffff || g != 0 || b != 0 { + t.Fatalf("expected red pixel at center with zero opacity, got r=%d g=%d b=%d", r>>8, g>>8, b>>8) + } +} + +func TestWatermarkAnchors(t *testing.T) { + tests := []struct { + anchor Anchor + expectDx int + expectDy int + }{ + {AnchorTopLeft, 0, 0}, + {AnchorTopRight, -10, 0}, + {AnchorBottomLeft, 0, -10}, + {AnchorBottomRight, -10, -10}, + {AnchorCenter, -5, -5}, + } + for _, tc := range tests { + dx, dy := anchorOffset(tc.anchor, 10, 10) + if dx != tc.expectDx || dy != tc.expectDy { + t.Errorf("anchor %s: expected (%d,%d), got (%d,%d)", tc.anchor, tc.expectDx, tc.expectDy, dx, dy) + } + } +} + +func TestWatermarkInvalidOpacity(t *testing.T) { + base := makeTestPNG(100, 100, color.White) + overlay := makeTestPNG(10, 10, color.Black) + + cs := makeParams("o", 0.5, 0.5, AnchorCenter, 1.5, 0.1) + editor := &WatermarkEditor{} + _, _, err := editor.Apply(base, cs, func(id string) ([]byte, error) { + return overlay, nil + }) + if err == nil { + t.Fatal("expected error for opacity > 1") + } +} + +func TestWatermarkInvalidScale(t *testing.T) { + base := makeTestPNG(100, 100, color.White) + overlay := makeTestPNG(10, 10, color.Black) + + cs := makeParams("o", 0.5, 0.5, AnchorCenter, 0.5, 0.0) + editor := &WatermarkEditor{} + _, _, err := editor.Apply(base, cs, func(id string) ([]byte, error) { + return overlay, nil + }) + if err == nil { + t.Fatal("expected error for scale == 0") + } +} + +func TestWatermarkOverlayLargerThanBase(t *testing.T) { + base := makeTestPNG(50, 50, color.RGBA{255, 0, 0, 255}) + overlay := makeTestPNG(500, 500, color.RGBA{0, 255, 0, 255}) + + cs := makeParams("big", 0.5, 0.5, AnchorCenter, 0.8, 0.5) + editor := &WatermarkEditor{} + result, _, err := editor.Apply(base, cs, func(id string) ([]byte, error) { + return overlay, nil + }) + if err != nil { + t.Fatal(err) + } + + resultImg, _ := png.Decode(bytes.NewReader(result)) + bounds := resultImg.Bounds() + if bounds.Dx() != 50 || bounds.Dy() != 50 { + t.Fatalf("result should match base dimensions: expected 50x50, got %dx%d", bounds.Dx(), bounds.Dy()) + } +} + +func TestWatermarkCornerPositions(t *testing.T) { + base := makeTestPNG(200, 200, color.RGBA{255, 0, 0, 255}) + overlay := makeTestPNG(20, 20, color.RGBA{0, 0, 255, 255}) + + // Place at bottom-right corner with bottom-right anchor + cs := makeParams("o", 1.0, 1.0, AnchorBottomRight, 1.0, 0.1) + editor := &WatermarkEditor{} + result, _, err := editor.Apply(base, cs, func(id string) ([]byte, error) { + return overlay, nil + }) + if err != nil { + t.Fatal(err) + } + + resultImg, _ := png.Decode(bytes.NewReader(result)) + // The pixel just inside the bottom-right corner should be blue-ish + r, _, b, _ := resultImg.At(199, 199).RGBA() + if b == 0 && r == 0xffff { + t.Fatal("expected bottom-right corner to have overlay color") + } +} diff --git a/gqlgen.yml b/gqlgen.yml index baacfde..2a84872 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -127,3 +127,7 @@ models: resolver: true createdBy: resolver: true + parent: + resolver: true + changes: + resolver: true diff --git a/graph/generated.go b/graph/generated.go index 028cfc6..4dbd018 100644 --- a/graph/generated.go +++ b/graph/generated.go @@ -44,6 +44,10 @@ type DirectiveRoot struct { } type ComplexityRoot struct { + ApplyWatermarkResult struct { + Image func(childComplexity int) int + } + DeleteImageResult struct { ID func(childComplexity int) int } @@ -59,6 +63,7 @@ type ComplexityRoot struct { } Image struct { + Changes func(childComplexity int) int CreatedAt func(childComplexity int) int CreatedBy func(childComplexity int) int ID func(childComplexity int) int @@ -68,6 +73,7 @@ type ComplexityRoot struct { NominalByteSize func(childComplexity int) int NominalHeight func(childComplexity int) int NominalWidth func(childComplexity int) int + Parent func(childComplexity int) int Revisions func(childComplexity int) int Root func(childComplexity int) int StoredImages func(childComplexity int) int @@ -94,6 +100,7 @@ type ComplexityRoot struct { } Mutation struct { + ApplyWatermark func(childComplexity int, input model.ApplyWatermarkInput) int Authenticate func(childComplexity int, email string, password string, organizationID *string) int CheckStorageDefinitionConnectivity func(childComplexity int, input model.CheckStorageDefinitionConnectivityInput) int CreateStorageDefinition func(childComplexity int, input model.CreateStorageDefinitionInput) int @@ -218,6 +225,8 @@ type ImageResolver interface { URL(ctx context.Context, obj *model.Image) (string, error) Root(ctx context.Context, obj *model.Image) (*model.Image, error) + Parent(ctx context.Context, obj *model.Image) (*model.Image, error) + Changes(ctx context.Context, obj *model.Image) (*string, error) Revisions(ctx context.Context, obj *model.Image) ([]*model.Image, error) StoredImages(ctx context.Context, obj *model.Image) ([]*model.StoredImage, error) @@ -231,6 +240,7 @@ type MutationResolver interface { SendResetPasswordEmail(ctx context.Context, input model.SendResetPasswordEmailInput) (*model.SendResetPasswordEmailResult, error) ResetPassword(ctx context.Context, input model.ResetPasswordInput) (*model.ResetPasswordResult, error) DeleteImage(ctx context.Context, input model.DeleteImageInput) (*model.DeleteImageResult, error) + ApplyWatermark(ctx context.Context, input model.ApplyWatermarkInput) (*model.ApplyWatermarkResult, error) CreateStorageDefinition(ctx context.Context, input model.CreateStorageDefinitionInput) (*model.StorageDefinition, error) UpdateStorageDefinition(ctx context.Context, input model.UpdateStorageDefinitionInput) (*model.StorageDefinition, error) CheckStorageDefinitionConnectivity(ctx context.Context, input model.CheckStorageDefinitionConnectivityInput) (*model.StorageDefinitionConnectivityResult, error) @@ -270,6 +280,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin _ = ec switch typeName + "." + field { + case "ApplyWatermarkResult.image": + if e.ComplexityRoot.ApplyWatermarkResult.Image == nil { + break + } + + return e.ComplexityRoot.ApplyWatermarkResult.Image(childComplexity), true + case "DeleteImageResult.id": if e.ComplexityRoot.DeleteImageResult.ID == nil { break @@ -303,6 +320,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.IPFSMFSStorageConfig.Pin(childComplexity), true + case "Image.changes": + if e.ComplexityRoot.Image.Changes == nil { + break + } + + return e.ComplexityRoot.Image.Changes(childComplexity), true case "Image.createdAt": if e.ComplexityRoot.Image.CreatedAt == nil { break @@ -357,6 +380,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Image.NominalWidth(childComplexity), true + case "Image.parent": + if e.ComplexityRoot.Image.Parent == nil { + break + } + + return e.ComplexityRoot.Image.Parent(childComplexity), true case "Image.revisions": if e.ComplexityRoot.Image.Revisions == nil { break @@ -445,6 +474,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.ImagesResult.PageInfo(childComplexity), true + case "Mutation.applyWatermark": + if e.ComplexityRoot.Mutation.ApplyWatermark == nil { + break + } + + args, err := ec.field_Mutation_applyWatermark_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.ApplyWatermark(childComplexity, args["input"].(model.ApplyWatermarkInput)), true case "Mutation.authenticate": if e.ComplexityRoot.Mutation.Authenticate == nil { break @@ -920,12 +960,14 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { opCtx := graphql.GetOperationContext(ctx) ec := newExecutionContext(opCtx, e, make(chan graphql.DeferredResult)) inputUnmarshalMap := graphql.BuildUnmarshalerMap( + ec.unmarshalInputApplyWatermarkInput, ec.unmarshalInputCreateUserWithOrganizationInput, ec.unmarshalInputDeleteImageInput, ec.unmarshalInputImageFilterInput, ec.unmarshalInputImageOrderByInput, ec.unmarshalInputResetPasswordInput, ec.unmarshalInputSendResetPasswordEmailInput, + ec.unmarshalInputWatermarkPositionInput, ec.unmarshalInputcheckStorageDefinitionConnectivityInput, ec.unmarshalInputcreateStorageDefinitionInput, ec.unmarshalInputupdateStorageDefinitionInput, @@ -1045,6 +1087,17 @@ func (ec *executionContext) dir_captchaProtected_args(ctx context.Context, rawAr return args, nil } +func (ec *executionContext) field_Mutation_applyWatermark_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNApplyWatermarkInput2githubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐApplyWatermarkInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_authenticate_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1307,6 +1360,67 @@ func (ec *executionContext) field___Type_fields_args(ctx context.Context, rawArg // region **************************** field.gotpl ***************************** +func (ec *executionContext) _ApplyWatermarkResult_image(ctx context.Context, field graphql.CollectedField, obj *model.ApplyWatermarkResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ApplyWatermarkResult_image, + func(ctx context.Context) (any, error) { + return obj.Image, nil + }, + nil, + ec.marshalOImage2ᚖgithubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐImage, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_ApplyWatermarkResult_image(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ApplyWatermarkResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Image_id(ctx, field) + case "url": + return ec.fieldContext_Image_url(ctx, field) + case "name": + return ec.fieldContext_Image_name(ctx, field) + case "identifier": + return ec.fieldContext_Image_identifier(ctx, field) + case "nominalWidth": + return ec.fieldContext_Image_nominalWidth(ctx, field) + case "nominalHeight": + return ec.fieldContext_Image_nominalHeight(ctx, field) + case "nominalByteSize": + return ec.fieldContext_Image_nominalByteSize(ctx, field) + case "root": + return ec.fieldContext_Image_root(ctx, field) + case "parent": + return ec.fieldContext_Image_parent(ctx, field) + case "changes": + return ec.fieldContext_Image_changes(ctx, field) + case "revisions": + return ec.fieldContext_Image_revisions(ctx, field) + case "createdAt": + return ec.fieldContext_Image_createdAt(ctx, field) + case "storedImages": + return ec.fieldContext_Image_storedImages(ctx, field) + case "MIMEType": + return ec.fieldContext_Image_MIMEType(ctx, field) + case "createdBy": + return ec.fieldContext_Image_createdBy(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Image", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _DeleteImageResult_id(ctx context.Context, field graphql.CollectedField, obj *model.DeleteImageResult) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -1695,6 +1809,71 @@ func (ec *executionContext) fieldContext_Image_root(_ context.Context, field gra return ec.fieldContext_Image_nominalByteSize(ctx, field) case "root": return ec.fieldContext_Image_root(ctx, field) + case "parent": + return ec.fieldContext_Image_parent(ctx, field) + case "changes": + return ec.fieldContext_Image_changes(ctx, field) + case "revisions": + return ec.fieldContext_Image_revisions(ctx, field) + case "createdAt": + return ec.fieldContext_Image_createdAt(ctx, field) + case "storedImages": + return ec.fieldContext_Image_storedImages(ctx, field) + case "MIMEType": + return ec.fieldContext_Image_MIMEType(ctx, field) + case "createdBy": + return ec.fieldContext_Image_createdBy(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Image", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Image_parent(ctx context.Context, field graphql.CollectedField, obj *model.Image) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Image_parent, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Image().Parent(ctx, obj) + }, + nil, + ec.marshalOImage2ᚖgithubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐImage, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Image_parent(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Image", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Image_id(ctx, field) + case "url": + return ec.fieldContext_Image_url(ctx, field) + case "name": + return ec.fieldContext_Image_name(ctx, field) + case "identifier": + return ec.fieldContext_Image_identifier(ctx, field) + case "nominalWidth": + return ec.fieldContext_Image_nominalWidth(ctx, field) + case "nominalHeight": + return ec.fieldContext_Image_nominalHeight(ctx, field) + case "nominalByteSize": + return ec.fieldContext_Image_nominalByteSize(ctx, field) + case "root": + return ec.fieldContext_Image_root(ctx, field) + case "parent": + return ec.fieldContext_Image_parent(ctx, field) + case "changes": + return ec.fieldContext_Image_changes(ctx, field) case "revisions": return ec.fieldContext_Image_revisions(ctx, field) case "createdAt": @@ -1712,6 +1891,35 @@ func (ec *executionContext) fieldContext_Image_root(_ context.Context, field gra return fc, nil } +func (ec *executionContext) _Image_changes(ctx context.Context, field graphql.CollectedField, obj *model.Image) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Image_changes, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Image().Changes(ctx, obj) + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Image_changes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Image", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Image_revisions(ctx context.Context, field graphql.CollectedField, obj *model.Image) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -1752,6 +1960,10 @@ func (ec *executionContext) fieldContext_Image_revisions(_ context.Context, fiel return ec.fieldContext_Image_nominalByteSize(ctx, field) case "root": return ec.fieldContext_Image_root(ctx, field) + case "parent": + return ec.fieldContext_Image_parent(ctx, field) + case "changes": + return ec.fieldContext_Image_changes(ctx, field) case "revisions": return ec.fieldContext_Image_revisions(ctx, field) case "createdAt": @@ -1943,6 +2155,10 @@ func (ec *executionContext) fieldContext_ImageEdge_node(_ context.Context, field return ec.fieldContext_Image_nominalByteSize(ctx, field) case "root": return ec.fieldContext_Image_root(ctx, field) + case "parent": + return ec.fieldContext_Image_parent(ctx, field) + case "changes": + return ec.fieldContext_Image_changes(ctx, field) case "revisions": return ec.fieldContext_Image_revisions(ctx, field) case "createdAt": @@ -2548,6 +2764,64 @@ func (ec *executionContext) fieldContext_Mutation_deleteImage(ctx context.Contex return fc, nil } +func (ec *executionContext) _Mutation_applyWatermark(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_applyWatermark, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().ApplyWatermark(ctx, fc.Args["input"].(model.ApplyWatermarkInput)) + }, + func(ctx context.Context, next graphql.Resolver) graphql.Resolver { + directive0 := next + + directive1 := func(ctx context.Context) (any, error) { + if ec.Directives.IsAuthenticated == nil { + var zeroVal *model.ApplyWatermarkResult + return zeroVal, errors.New("directive isAuthenticated is not implemented") + } + return ec.Directives.IsAuthenticated(ctx, nil, directive0) + } + + next = directive1 + return next + }, + ec.marshalNApplyWatermarkResult2ᚖgithubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐApplyWatermarkResult, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_applyWatermark(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "image": + return ec.fieldContext_ApplyWatermarkResult_image(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type ApplyWatermarkResult", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_applyWatermark_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_createStorageDefinition(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -6175,6 +6449,71 @@ func (ec *executionContext) fieldContext___Type_isOneOf(_ context.Context, field // region **************************** input.gotpl ***************************** +func (ec *executionContext) unmarshalInputApplyWatermarkInput(ctx context.Context, obj any) (model.ApplyWatermarkInput, error) { + var it model.ApplyWatermarkInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"baseImageId", "overlayImageId", "position", "anchor", "opacity", "scale"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "baseImageId": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("baseImageId")) + data, err := ec.unmarshalNID2string(ctx, v) + if err != nil { + return it, err + } + it.BaseImageID = data + case "overlayImageId": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("overlayImageId")) + data, err := ec.unmarshalNID2string(ctx, v) + if err != nil { + return it, err + } + it.OverlayImageID = data + case "position": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("position")) + data, err := ec.unmarshalNWatermarkPositionInput2ᚖgithubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐWatermarkPositionInput(ctx, v) + if err != nil { + return it, err + } + it.Position = data + case "anchor": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("anchor")) + data, err := ec.unmarshalNAnchor2githubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐAnchor(ctx, v) + if err != nil { + return it, err + } + it.Anchor = data + case "opacity": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("opacity")) + data, err := ec.unmarshalNFloat2float64(ctx, v) + if err != nil { + return it, err + } + it.Opacity = data + case "scale": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("scale")) + data, err := ec.unmarshalNFloat2float64(ctx, v) + if err != nil { + return it, err + } + it.Scale = data + } + } + return it, nil +} + func (ec *executionContext) unmarshalInputCreateUserWithOrganizationInput(ctx context.Context, obj any) (model.CreateUserWithOrganizationInput, error) { var it model.CreateUserWithOrganizationInput if obj == nil { @@ -6411,6 +6750,43 @@ func (ec *executionContext) unmarshalInputSendResetPasswordEmailInput(ctx contex return it, nil } +func (ec *executionContext) unmarshalInputWatermarkPositionInput(ctx context.Context, obj any) (model.WatermarkPositionInput, error) { + var it model.WatermarkPositionInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"x", "y"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "x": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("x")) + data, err := ec.unmarshalNFloat2float64(ctx, v) + if err != nil { + return it, err + } + it.X = data + case "y": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("y")) + data, err := ec.unmarshalNFloat2float64(ctx, v) + if err != nil { + return it, err + } + it.Y = data + } + } + return it, nil +} + func (ec *executionContext) unmarshalInputcheckStorageDefinitionConnectivityInput(ctx context.Context, obj any) (model.CheckStorageDefinitionConnectivityInput, error) { var it model.CheckStorageDefinitionConnectivityInput if obj == nil { @@ -6606,6 +6982,42 @@ func (ec *executionContext) _StorageConfig(ctx context.Context, sel ast.Selectio // region **************************** object.gotpl **************************** +var applyWatermarkResultImplementors = []string{"ApplyWatermarkResult"} + +func (ec *executionContext) _ApplyWatermarkResult(ctx context.Context, sel ast.SelectionSet, obj *model.ApplyWatermarkResult) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, applyWatermarkResultImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ApplyWatermarkResult") + case "image": + out.Values[i] = ec._ApplyWatermarkResult_image(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var deleteImageResultImplementors = []string{"DeleteImageResult"} func (ec *executionContext) _DeleteImageResult(ctx context.Context, sel ast.SelectionSet, obj *model.DeleteImageResult) graphql.Marshaler { @@ -6839,6 +7251,72 @@ func (ec *executionContext) _Image(ctx context.Context, sel ast.SelectionSet, ob continue } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "parent": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Image_parent(ctx, field, obj) + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "changes": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Image_changes(ctx, field, obj) + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "revisions": field := field @@ -7179,6 +7657,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "applyWatermark": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_applyWatermark(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "createStorageDefinition": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_createStorageDefinition(ctx, field) @@ -8790,6 +9275,35 @@ func (ec *executionContext) ___Type(ctx context.Context, sel ast.SelectionSet, o // region ***************************** type.gotpl ***************************** +func (ec *executionContext) unmarshalNAnchor2githubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐAnchor(ctx context.Context, v any) (model.Anchor, error) { + var res model.Anchor + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNAnchor2githubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐAnchor(ctx context.Context, sel ast.SelectionSet, v model.Anchor) graphql.Marshaler { + return v +} + +func (ec *executionContext) unmarshalNApplyWatermarkInput2githubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐApplyWatermarkInput(ctx context.Context, v any) (model.ApplyWatermarkInput, error) { + res, err := ec.unmarshalInputApplyWatermarkInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNApplyWatermarkResult2githubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐApplyWatermarkResult(ctx context.Context, sel ast.SelectionSet, v model.ApplyWatermarkResult) graphql.Marshaler { + return ec._ApplyWatermarkResult(ctx, sel, &v) +} + +func (ec *executionContext) marshalNApplyWatermarkResult2ᚖgithubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐApplyWatermarkResult(ctx context.Context, sel ast.SelectionSet, v *model.ApplyWatermarkResult) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._ApplyWatermarkResult(ctx, sel, v) +} + func (ec *executionContext) unmarshalNBoolean2bool(ctx context.Context, v any) (bool, error) { res, err := graphql.UnmarshalBoolean(v) return res, graphql.ErrorOnPath(ctx, err) @@ -8830,6 +9344,22 @@ func (ec *executionContext) marshalNDeleteImageResult2ᚖgithubᚗcomᚋericls return ec._DeleteImageResult(ctx, sel, v) } +func (ec *executionContext) unmarshalNFloat2float64(ctx context.Context, v any) (float64, error) { + res, err := graphql.UnmarshalFloatContext(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNFloat2float64(ctx context.Context, sel ast.SelectionSet, v float64) graphql.Marshaler { + _ = sel + res := graphql.MarshalFloatContext(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + } + return graphql.WrapContextMarshaler(ctx, res) +} + func (ec *executionContext) unmarshalNID2string(ctx context.Context, v any) (string, error) { res, err := graphql.UnmarshalID(v) return res, graphql.ErrorOnPath(ctx, err) @@ -9298,6 +9828,11 @@ func (ec *executionContext) marshalNViewerResult2ᚖgithubᚗcomᚋericlsᚋimgd return ec._ViewerResult(ctx, sel, v) } +func (ec *executionContext) unmarshalNWatermarkPositionInput2ᚖgithubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐWatermarkPositionInput(ctx context.Context, v any) (*model.WatermarkPositionInput, error) { + res, err := ec.unmarshalInputWatermarkPositionInput(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) marshalN__Directive2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐDirective(ctx context.Context, sel ast.SelectionSet, v introspection.Directive) graphql.Marshaler { return ec.___Directive(ctx, sel, &v) } diff --git a/graph/graph_test.go b/graph/graph_test.go index 9c65496..e79244d 100644 --- a/graph/graph_test.go +++ b/graph/graph_test.go @@ -94,6 +94,7 @@ func newTestContext(tObj *testing.T) *TestContext { resolver := httpserver.NewGqlResolver( identityManager, storageDefRepo, + storedImageRepo, imageRepo, "", domainmodels.ImageURLFormat_CANONICAL, diff --git a/graph/images.resolvers.go b/graph/images.resolvers.go index 9438e1c..1d726f9 100644 --- a/graph/images.resolvers.go +++ b/graph/images.resolvers.go @@ -7,12 +7,17 @@ package graph import ( "context" + "encoding/json" "fmt" "github.com/ericls/imgdd/domainmodels" + "github.com/ericls/imgdd/editing" "github.com/ericls/imgdd/graph/model" "github.com/ericls/imgdd/identity" "github.com/ericls/imgdd/image" + "github.com/ericls/imgdd/storage" + "github.com/ericls/imgdd/utils" + "github.com/google/uuid" ) // URL is the resolver for the url field. @@ -46,6 +51,29 @@ func (r *imageResolver) Root(ctx context.Context, obj *model.Image) (*model.Imag return nil, nil } +// Parent is the resolver for the parent field. +func (r *imageResolver) Parent(ctx context.Context, obj *model.Image) (*model.Image, error) { + if obj.ParentId == "" { + return nil, nil + } + parent, err := r.ImageRepo.GetImageById(obj.ParentId) + if err != nil { + return nil, err + } + if parent == nil { + return nil, nil + } + return model.FromImage(parent), nil +} + +// Changes is the resolver for the changes field. +func (r *imageResolver) Changes(ctx context.Context, obj *model.Image) (*string, error) { + if obj.RawChanges == "" || obj.RawChanges == "{}" { + return nil, nil + } + return &obj.RawChanges, nil +} + // Revisions is the resolver for the revisions field. func (r *imageResolver) Revisions(ctx context.Context, obj *model.Image) ([]*model.Image, error) { // TODO: Implement this method after we support revisions. @@ -86,6 +114,131 @@ func (r *mutationResolver) DeleteImage(ctx context.Context, input model.DeleteIm } } +// ApplyWatermark is the resolver for the applyWatermark field. +func (r *mutationResolver) ApplyWatermark(ctx context.Context, input model.ApplyWatermarkInput) (*model.ApplyWatermarkResult, error) { + currentUser := identity.GetCurrentOrganizationUser(r.ContextUserManager, ctx) + if currentUser == nil { + return nil, fmt.Errorf("unauthorized") + } + + // Validate both images exist and are owned by the current user + baseImage, err := r.ImageRepo.GetImageById(input.BaseImageID) + if err != nil || baseImage == nil { + return nil, fmt.Errorf("base image not found") + } + if baseImage.CreatedById != currentUser.Id && !currentUser.IsSiteOwner() { + return nil, fmt.Errorf("unauthorized") + } + + overlayImage, err := r.ImageRepo.GetImageById(input.OverlayImageID) + if err != nil || overlayImage == nil { + return nil, fmt.Errorf("overlay image not found") + } + + // Map GraphQL anchor to editing anchor + anchorMap := map[model.Anchor]editing.Anchor{ + model.AnchorTopLeft: editing.AnchorTopLeft, + model.AnchorTopRight: editing.AnchorTopRight, + model.AnchorBottomLeft: editing.AnchorBottomLeft, + model.AnchorBottomRight: editing.AnchorBottomRight, + model.AnchorCenter: editing.AnchorCenter, + } + + params := editing.WatermarkParams{ + OverlayImageID: input.OverlayImageID, + Position: editing.WatermarkPosition{ + X: input.Position.X, + Y: input.Position.Y, + }, + Anchor: anchorMap[input.Anchor], + Opacity: input.Opacity, + Scale: input.Scale, + } + paramsJSON, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("failed to serialize params: %w", err) + } + + cs := editing.ChangeSet{ + Type: "watermark", + Params: paramsJSON, + } + + // Get editor + editor, err := editing.GetEditor(cs.Type) + if err != nil { + return nil, err + } + + // Fetch base image bytes + fetchImage := editing.NewFetchImageFunc(r.StoredImageRepo, r.StorageDefRepo) + baseBytes, err := fetchImage(input.BaseImageID) + if err != nil { + return nil, fmt.Errorf("failed to fetch base image: %w", err) + } + + // Apply the edit + resultBytes, resultMime, err := editor.Apply(baseBytes, cs, fetchImage) + if err != nil { + return nil, fmt.Errorf("failed to apply watermark: %w", err) + } + + // Serialize the change set for storage + changesJSON, err := json.Marshal(cs) + if err != nil { + return nil, fmt.Errorf("failed to serialize changes: %w", err) + } + + // Get dimensions of the result + width, height, err := utils.GetImageDimensions(resultBytes) + if err != nil { + return nil, fmt.Errorf("failed to get result dimensions: %w", err) + } + + // Find storage backend + storageDefs, err := r.StorageDefRepo.ListStorageDefinitions() + if err != nil { + return nil, fmt.Errorf("failed to list storage definitions: %w", err) + } + var storageDef *domainmodels.StorageDefinition + for _, def := range storageDefs { + if def.IsEnabled { + storageDef = def + break + } + } + if storageDef == nil { + return nil, fmt.Errorf("no enabled storage backend") + } + + storageInstance, err := storage.GetStorage(storageDef) + if err != nil { + return nil, fmt.Errorf("failed to get storage: %w", err) + } + + // Create the new derived image + newImage := domainmodels.Image{ + Identifier: uuid.New().String(), + Name: baseImage.Name, + ParentId: baseImage.Id, + Changes: string(changesJSON), + CreatedById: currentUser.Id, + MIMEType: resultMime, + NominalWidth: width, + NominalHeight: height, + NominalByteSize: int32(len(resultBytes)), + } + + storedImage, err := r.ImageRepo.CreateAndSaveUploadedImage(&newImage, resultMime, resultBytes, storageDef.Id, storageInstance.Save) + if err != nil { + return nil, fmt.Errorf("failed to save result image: %w", err) + } + + return &model.ApplyWatermarkResult{ + Image: model.FromImage(storedImage.Image), + }, nil +} + // Images is the resolver for the images field. func (r *viewerResolver) Images(ctx context.Context, obj *model.Viewer, orderBy *model.ImageOrderByInput, filters *model.ImageFilterInput, after *string, before *string) (*model.ImagesResult, error) { currentUser := identity.GetCurrentOrganizationUser(r.ContextUserManager, ctx) diff --git a/graph/images.resolvers_test.go b/graph/images.resolvers_test.go index aa0fd31..92614d8 100644 --- a/graph/images.resolvers_test.go +++ b/graph/images.resolvers_test.go @@ -1,10 +1,16 @@ package graph_test import ( + "bytes" + "image" + "image/color" + "image/png" + "os" "testing" "github.com/ericls/imgdd/domainmodels" "github.com/ericls/imgdd/graph/model" + "github.com/ericls/imgdd/storage" "github.com/ericls/imgdd/utils" "github.com/99designs/gqlgen/client" @@ -460,6 +466,212 @@ func tImageCreatedByNullWhenNoCreator(t *testing.T, tc *TestContext) { require.Nil(t, resp.Viewer.Images.Edges[0].Node.CreatedBy) } +func makeTestPNGBytes(w, h int, c color.Color) []byte { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, c) + } + } + var buf bytes.Buffer + png.Encode(&buf, img) + return buf.Bytes() +} + +func createFSStorageDefinition(t *testing.T, tc *TestContext) (*domainmodels.StorageDefinition, string) { + tempDir, err := os.MkdirTemp("", "imgdd_test_*") + require.NoError(t, err) + configJSON := `{"mediaRoot": "` + tempDir + `"}` + sd, err := tc.storageDefRepo.CreateStorageDefinition("fs", configJSON, "test-fs", true, 1) + require.NoError(t, err) + return sd, tempDir +} + +func createRealImage(t *testing.T, tc *TestContext, uploaderId string, storageDefId string, imgBytes []byte) *domainmodels.Image { + identifier := uuid.New().String() + fakeImage := domainmodels.Image{ + UploaderIP: "127.0.0.1", + CreatedById: uploaderId, + MIMEType: "image/png", + Name: identifier + ".png", + Identifier: identifier, + NominalByteSize: int32(len(imgBytes)), + NominalWidth: 100, + NominalHeight: 100, + } + storageInstance, err := storage.GetStorage(&domainmodels.StorageDefinition{ + Id: storageDefId, + StorageType: "fs", + Config: func() string { sd, _ := tc.storageDefRepo.GetStorageDefinitionById(storageDefId); return sd.Config }(), + IsEnabled: true, + }) + require.NoError(t, err) + si, err := tc.imageRepo.CreateAndSaveUploadedImage(&fakeImage, "image/png", imgBytes, storageDefId, storageInstance.Save) + require.NoError(t, err) + return si.Image +} + +func tApplyWatermark(t *testing.T, tc *TestContext) { + orgUser := tc.forceAuthenticate() + sd, tempDir := createFSStorageDefinition(t, tc) + defer os.RemoveAll(tempDir) + + baseBytes := makeTestPNGBytes(200, 200, color.RGBA{255, 0, 0, 255}) + overlayBytes := makeTestPNGBytes(50, 50, color.RGBA{0, 0, 255, 255}) + baseImage := createRealImage(t, tc, orgUser.Id, sd.Id, baseBytes) + overlayImage := createRealImage(t, tc, orgUser.Id, sd.Id, overlayBytes) + + var resp struct { + ApplyWatermark *struct { + Image *struct { + ID string + Name string + MIMEType string + Parent *struct { + ID string + } + Changes *string + } + } + } + + err := tc.client.Post(` + mutation applyWatermark($input: ApplyWatermarkInput!) { + applyWatermark(input: $input) { + image { + id + name + MIMEType + parent { + id + } + changes + } + } + }`, &resp, client.Var("input", map[string]interface{}{ + "baseImageId": baseImage.Id, + "overlayImageId": overlayImage.Id, + "position": map[string]float64{"x": 0.9, "y": 0.9}, + "anchor": "BOTTOM_RIGHT", + "opacity": 0.5, + "scale": 0.15, + })) + require.NoError(t, err) + require.NotNil(t, resp.ApplyWatermark) + require.NotNil(t, resp.ApplyWatermark.Image) + require.NotEmpty(t, resp.ApplyWatermark.Image.ID) + require.Equal(t, baseImage.Name, resp.ApplyWatermark.Image.Name) + require.Equal(t, "image/png", resp.ApplyWatermark.Image.MIMEType) + + // Verify lineage + require.NotNil(t, resp.ApplyWatermark.Image.Parent) + require.Equal(t, baseImage.Id, resp.ApplyWatermark.Image.Parent.ID) + + // Verify changes JSON is populated + require.NotNil(t, resp.ApplyWatermark.Image.Changes) + require.Contains(t, *resp.ApplyWatermark.Image.Changes, `"type"`) + require.Contains(t, *resp.ApplyWatermark.Image.Changes, `watermark`) + + // Verify the new image exists in the DB with correct lineage + newImage, err := tc.imageRepo.GetImageById(resp.ApplyWatermark.Image.ID) + require.NoError(t, err) + require.Equal(t, baseImage.Id, newImage.ParentId) + require.Equal(t, baseImage.Id, newImage.RootId) +} + +func tApplyWatermarkUnauthenticated(t *testing.T, tc *TestContext) { + tc.clearAuthenticationInfo() + + var resp struct { + ApplyWatermark *struct { + Image *struct{ ID string } + } + } + + err := tc.client.Post(` + mutation applyWatermark($input: ApplyWatermarkInput!) { + applyWatermark(input: $input) { + image { + id + } + } + }`, &resp, client.Var("input", map[string]interface{}{ + "baseImageId": uuid.New().String(), + "overlayImageId": uuid.New().String(), + "position": map[string]float64{"x": 0.5, "y": 0.5}, + "anchor": "CENTER", + "opacity": 1.0, + "scale": 0.1, + })) + require.Error(t, err) +} + +func tApplyWatermarkUnauthorizedImage(t *testing.T, tc *TestContext) { + orgUser1 := tc.forceAuthenticate() + orgUser2 := tc.forceAuthenticate() + sd, tempDir := createFSStorageDefinition(t, tc) + defer os.RemoveAll(tempDir) + + baseBytes := makeTestPNGBytes(100, 100, color.White) + overlayBytes := makeTestPNGBytes(20, 20, color.Black) + // base image owned by orgUser1 + baseImage := createRealImage(t, tc, orgUser1.Id, sd.Id, baseBytes) + overlayImage := createRealImage(t, tc, orgUser2.Id, sd.Id, overlayBytes) + + // Authenticate as orgUser2 and try to edit orgUser1's image + tc.setAuthenticatedUser(orgUser2) + + var resp struct { + ApplyWatermark *struct { + Image *struct{ ID string } + } + } + + err := tc.client.Post(` + mutation applyWatermark($input: ApplyWatermarkInput!) { + applyWatermark(input: $input) { + image { + id + } + } + }`, &resp, client.Var("input", map[string]interface{}{ + "baseImageId": baseImage.Id, + "overlayImageId": overlayImage.Id, + "position": map[string]float64{"x": 0.5, "y": 0.5}, + "anchor": "CENTER", + "opacity": 1.0, + "scale": 0.1, + })) + require.Error(t, err) +} + +func tApplyWatermarkInvalidImageId(t *testing.T, tc *TestContext) { + tc.forceAuthenticate() + + var resp struct { + ApplyWatermark *struct { + Image *struct{ ID string } + } + } + + err := tc.client.Post(` + mutation applyWatermark($input: ApplyWatermarkInput!) { + applyWatermark(input: $input) { + image { + id + } + } + }`, &resp, client.Var("input", map[string]interface{}{ + "baseImageId": uuid.New().String(), + "overlayImageId": uuid.New().String(), + "position": map[string]float64{"x": 0.5, "y": 0.5}, + "anchor": "CENTER", + "opacity": 1.0, + "scale": 0.1, + })) + require.Error(t, err) +} + func TestImageResolvers(t *testing.T) { tc := newTestContext(t) tc.runTestCases( @@ -471,5 +683,9 @@ func TestImageResolvers(t *testing.T) { tDeletingImage, tImageCreatedByIsPopulated, tImageCreatedByNullWhenNoCreator, + tApplyWatermark, + tApplyWatermarkUnauthenticated, + tApplyWatermarkUnauthorizedImage, + tApplyWatermarkInvalidImageId, ) } diff --git a/graph/model/image.go b/graph/model/image.go index 0e247d9..ad855ff 100644 --- a/graph/model/image.go +++ b/graph/model/image.go @@ -18,6 +18,9 @@ type Image struct { MIMEType string `json:"MIMEType"` URL string `json:"url"` CreatedById string `json:"createdById"` + ParentId string `json:"parentId"` + RootId string `json:"rootId"` + RawChanges string `json:"rawChanges"` } func FromImage(i *domainmodels.Image) *Image { @@ -31,6 +34,9 @@ func FromImage(i *domainmodels.Image) *Image { CreatedAt: i.CreatedAt, MIMEType: i.MIMEType, CreatedById: i.CreatedById, + ParentId: i.ParentId, + RootId: i.RootId, + RawChanges: i.Changes, } } diff --git a/graph/model/models_gen.go b/graph/model/models_gen.go index ea78c86..e929665 100644 --- a/graph/model/models_gen.go +++ b/graph/model/models_gen.go @@ -9,6 +9,19 @@ import ( "strconv" ) +type ApplyWatermarkInput struct { + BaseImageID string `json:"baseImageId"` + OverlayImageID string `json:"overlayImageId"` + Position *WatermarkPositionInput `json:"position"` + Anchor Anchor `json:"anchor"` + Opacity float64 `json:"opacity"` + Scale float64 `json:"scale"` +} + +type ApplyWatermarkResult struct { + Image *Image `json:"image,omitempty"` +} + type CreateUserWithOrganizationInput struct { UserEmail string `json:"userEmail"` UserPassword string `json:"userPassword"` @@ -31,6 +44,72 @@ type ViewerResult struct { Viewer *Viewer `json:"viewer"` } +type WatermarkPositionInput struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type Anchor string + +const ( + AnchorTopLeft Anchor = "TOP_LEFT" + AnchorTopRight Anchor = "TOP_RIGHT" + AnchorBottomLeft Anchor = "BOTTOM_LEFT" + AnchorBottomRight Anchor = "BOTTOM_RIGHT" + AnchorCenter Anchor = "CENTER" +) + +var AllAnchor = []Anchor{ + AnchorTopLeft, + AnchorTopRight, + AnchorBottomLeft, + AnchorBottomRight, + AnchorCenter, +} + +func (e Anchor) IsValid() bool { + switch e { + case AnchorTopLeft, AnchorTopRight, AnchorBottomLeft, AnchorBottomRight, AnchorCenter: + return true + } + return false +} + +func (e Anchor) String() string { + return string(e) +} + +func (e *Anchor) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = Anchor(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid Anchor", str) + } + return nil +} + +func (e Anchor) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *Anchor) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e Anchor) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + type PaginationDirection string const ( diff --git a/graph/resolver.go b/graph/resolver.go index e7805b8..2bf255e 100644 --- a/graph/resolver.go +++ b/graph/resolver.go @@ -20,6 +20,7 @@ import ( type Resolver struct { IdentityRepo identity.IdentityRepo StorageDefRepo storage.StorageDefRepo + StoredImageRepo storage.StoredImageRepo ImageRepo image.ImageRepo ContextUserManager identity.ContextUserManager LoginFn func(c context.Context, userId string, organizationUserId string) diff --git a/graph/schema/images.graphqls b/graph/schema/images.graphqls index 1b6c3a6..9fb900b 100644 --- a/graph/schema/images.graphqls +++ b/graph/schema/images.graphqls @@ -13,6 +13,8 @@ type Image { nominalHeight: Int! nominalByteSize: Int! root: Image + parent: Image + changes: String revisions: [Image!]! createdAt: Time! storedImages: [StoredImage!]! @@ -20,6 +22,32 @@ type Image { createdBy: OrganizationUser } +enum Anchor { + TOP_LEFT + TOP_RIGHT + BOTTOM_LEFT + BOTTOM_RIGHT + CENTER +} + +input WatermarkPositionInput { + x: Float! + y: Float! +} + +input ApplyWatermarkInput { + baseImageId: ID! + overlayImageId: ID! + position: WatermarkPositionInput! + anchor: Anchor! + opacity: Float! + scale: Float! +} + +type ApplyWatermarkResult { + image: Image +} + input ImageOrderByInput { id: PaginationDirection name: PaginationDirection @@ -71,4 +99,5 @@ type DeleteImageResult { extend type Mutation { deleteImage(input: DeleteImageInput!): DeleteImageResult! @isAuthenticated + applyWatermark(input: ApplyWatermarkInput!): ApplyWatermarkResult! @isAuthenticated } diff --git a/httpserver/make_server.go b/httpserver/make_server.go index b09df5e..2eaa0dd 100644 --- a/httpserver/make_server.go +++ b/httpserver/make_server.go @@ -91,6 +91,7 @@ func MakeServer( gqlResolver := NewGqlResolver( identityManager, storageDefRepo, + storedImageRepo, imageRepo, conf.ImageDomain, conf.DefaultURLFormat, diff --git a/httpserver/utils.go b/httpserver/utils.go index 398c755..833a6ff 100644 --- a/httpserver/utils.go +++ b/httpserver/utils.go @@ -16,6 +16,7 @@ type ContextKey string func NewGqlResolver( identityManager *IdentityManager, storageDefRepo storage.StorageDefRepo, + storedImageRepo storage.StoredImageRepo, imageRepo image.ImageRepo, imageDomain string, defaultURLFormat domainmodels.ImageURLFormat, @@ -27,6 +28,7 @@ func NewGqlResolver( return &graph.Resolver{ IdentityRepo: identityManager.IdentityRepo, StorageDefRepo: storageDefRepo, + StoredImageRepo: storedImageRepo, ImageRepo: imageRepo, ContextUserManager: identityManager.ContextUserManager, LoginFn: identityManager.AuthenticateContext, diff --git a/image/repo.go b/image/repo.go index bd84be8..cc9cbdc 100644 --- a/image/repo.go +++ b/image/repo.go @@ -70,6 +70,7 @@ func (repo *DBImageRepo) GetImageById(id string) (*dm.Image, error) { Name: dest.Name, ParentId: parentId, RootId: rootId, + Changes: dest.Changes, UploaderIP: utils.SafeDeref(dest.UploaderIP), MIMEType: dest.MimeType, NominalWidth: dest.NominalWidth, @@ -103,11 +104,22 @@ func (repo *DBImageRepo) CreateImage(image *dm.Image) (*dm.Image, error) { createdById = nil } + var uploaderIP *string + if image.UploaderIP != "" { + uploaderIP = &image.UploaderIP + } + + changes := image.Changes + if changes == "" { + changes = "{}" + } + stmt := ImageTable.INSERT( ImageTable.Identifier, ImageTable.Name, ImageTable.ParentID, ImageTable.RootID, + ImageTable.Changes, ImageTable.UploaderIP, ImageTable.CreatedByID, ImageTable.MimeType, @@ -119,7 +131,8 @@ func (repo *DBImageRepo) CreateImage(image *dm.Image) (*dm.Image, error) { image.Name, parentId, rootId, - image.UploaderIP, + changes, + uploaderIP, createdById, image.MIMEType, image.NominalByteSize, @@ -370,6 +383,7 @@ func (repo *DBImageRepo) ListImages( Name: image.Name, ParentId: utils.SafeDeref(image.ParentID).String(), RootId: utils.SafeDeref(image.RootID).String(), + Changes: image.Changes, UploaderIP: utils.SafeDeref(image.UploaderIP), MIMEType: image.MimeType, NominalWidth: image.NominalWidth, From 093bd6d4a4a92c3d98b9c5dc033d0307a177e25d Mon Sep 17 00:00:00 2001 From: shen Date: Mon, 1 Jun 2026 02:05:51 -0400 Subject: [PATCH 02/15] WIP frontend with image editor --- graph/generated.go | 266 +++++++++++++++++++ graph/images.resolvers.go | 78 +++++- graph/schema/images.graphqls | 2 + httpserver/image_handlers.go | 2 + web_client/src/__generated__/gql.ts | 18 ++ web_client/src/__generated__/graphql.ts | 66 +++++ web_client/src/common/ImageGallery/menu.tsx | 38 ++- web_client/src/editor/EditorCanvas.tsx | 186 ++++++++++++++ web_client/src/editor/ImageDetail.tsx | 270 ++++++++++++++++++++ web_client/src/editor/ImageEditor.tsx | 199 +++++++++++++++ web_client/src/editor/WatermarkTool.tsx | 223 ++++++++++++++++ web_client/src/editor/data.tsx | 29 +++ web_client/src/entry.tsx | 30 ++- web_client/src/routes.ts | 4 + web_client/src/uploader/uploader.tsx | 22 +- 15 files changed, 1425 insertions(+), 8 deletions(-) create mode 100644 web_client/src/editor/EditorCanvas.tsx create mode 100644 web_client/src/editor/ImageDetail.tsx create mode 100644 web_client/src/editor/ImageEditor.tsx create mode 100644 web_client/src/editor/WatermarkTool.tsx create mode 100644 web_client/src/editor/data.tsx diff --git a/graph/generated.go b/graph/generated.go index 4dbd018..a5c4dff 100644 --- a/graph/generated.go +++ b/graph/generated.go @@ -68,6 +68,7 @@ type ComplexityRoot struct { CreatedBy func(childComplexity int) int ID func(childComplexity int) int Identifier func(childComplexity int) int + Lineage func(childComplexity int) int MIMEType func(childComplexity int) int Name func(childComplexity int) int NominalByteSize func(childComplexity int) int @@ -202,6 +203,7 @@ type ComplexityRoot struct { GetStorageDefinition func(childComplexity int, id string) int HasPermission func(childComplexity int, permission model.PermissionNameEnum) int ID func(childComplexity int) int + Image func(childComplexity int, id string) int Images func(childComplexity int, orderBy *model.ImageOrderByInput, filters *model.ImageFilterInput, after *string, before *string) int OrganizationUser func(childComplexity int) int OrganizationUserByID func(childComplexity int, id string) int @@ -227,6 +229,7 @@ type ImageResolver interface { Root(ctx context.Context, obj *model.Image) (*model.Image, error) Parent(ctx context.Context, obj *model.Image) (*model.Image, error) Changes(ctx context.Context, obj *model.Image) (*string, error) + Lineage(ctx context.Context, obj *model.Image) ([]*model.Image, error) Revisions(ctx context.Context, obj *model.Image) ([]*model.Image, error) StoredImages(ctx context.Context, obj *model.Image) ([]*model.StoredImage, error) @@ -258,6 +261,7 @@ type ViewerResolver interface { ID(ctx context.Context, obj *model.Viewer) (string, error) OrganizationUser(ctx context.Context, obj *model.Viewer) (*model.OrganizationUser, error) OrganizationUserByID(ctx context.Context, obj *model.Viewer, id string) (*model.OrganizationUser, error) + Image(ctx context.Context, obj *model.Viewer, id string) (*model.Image, error) Images(ctx context.Context, obj *model.Viewer, orderBy *model.ImageOrderByInput, filters *model.ImageFilterInput, after *string, before *string) (*model.ImagesResult, error) HasPermission(ctx context.Context, obj *model.Viewer, permission model.PermissionNameEnum) (bool, error) StorageDefinitions(ctx context.Context, obj *model.Viewer) ([]*model.StorageDefinition, error) @@ -350,6 +354,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Image.Identifier(childComplexity), true + case "Image.lineage": + if e.ComplexityRoot.Image.Lineage == nil { + break + } + + return e.ComplexityRoot.Image.Lineage(childComplexity), true case "Image.MIMEType": if e.ComplexityRoot.Image.MIMEType == nil { break @@ -874,6 +884,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Viewer.ID(childComplexity), true + case "Viewer.image": + if e.ComplexityRoot.Viewer.Image == nil { + break + } + + args, err := ec.field_Viewer_image_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Viewer.Image(childComplexity, args["id"].(string)), true case "Viewer.images": if e.ComplexityRoot.Viewer.Images == nil { break @@ -1250,6 +1271,17 @@ func (ec *executionContext) field_Viewer_hasPermission_args(ctx context.Context, return args, nil } +func (ec *executionContext) field_Viewer_image_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["id"] = arg0 + return args, nil +} + func (ec *executionContext) field_Viewer_images_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1404,6 +1436,8 @@ func (ec *executionContext) fieldContext_ApplyWatermarkResult_image(_ context.Co return ec.fieldContext_Image_parent(ctx, field) case "changes": return ec.fieldContext_Image_changes(ctx, field) + case "lineage": + return ec.fieldContext_Image_lineage(ctx, field) case "revisions": return ec.fieldContext_Image_revisions(ctx, field) case "createdAt": @@ -1813,6 +1847,8 @@ func (ec *executionContext) fieldContext_Image_root(_ context.Context, field gra return ec.fieldContext_Image_parent(ctx, field) case "changes": return ec.fieldContext_Image_changes(ctx, field) + case "lineage": + return ec.fieldContext_Image_lineage(ctx, field) case "revisions": return ec.fieldContext_Image_revisions(ctx, field) case "createdAt": @@ -1874,6 +1910,8 @@ func (ec *executionContext) fieldContext_Image_parent(_ context.Context, field g return ec.fieldContext_Image_parent(ctx, field) case "changes": return ec.fieldContext_Image_changes(ctx, field) + case "lineage": + return ec.fieldContext_Image_lineage(ctx, field) case "revisions": return ec.fieldContext_Image_revisions(ctx, field) case "createdAt": @@ -1920,6 +1958,69 @@ func (ec *executionContext) fieldContext_Image_changes(_ context.Context, field return fc, nil } +func (ec *executionContext) _Image_lineage(ctx context.Context, field graphql.CollectedField, obj *model.Image) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Image_lineage, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Image().Lineage(ctx, obj) + }, + nil, + ec.marshalNImage2ᚕᚖgithubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐImageᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Image_lineage(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Image", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Image_id(ctx, field) + case "url": + return ec.fieldContext_Image_url(ctx, field) + case "name": + return ec.fieldContext_Image_name(ctx, field) + case "identifier": + return ec.fieldContext_Image_identifier(ctx, field) + case "nominalWidth": + return ec.fieldContext_Image_nominalWidth(ctx, field) + case "nominalHeight": + return ec.fieldContext_Image_nominalHeight(ctx, field) + case "nominalByteSize": + return ec.fieldContext_Image_nominalByteSize(ctx, field) + case "root": + return ec.fieldContext_Image_root(ctx, field) + case "parent": + return ec.fieldContext_Image_parent(ctx, field) + case "changes": + return ec.fieldContext_Image_changes(ctx, field) + case "lineage": + return ec.fieldContext_Image_lineage(ctx, field) + case "revisions": + return ec.fieldContext_Image_revisions(ctx, field) + case "createdAt": + return ec.fieldContext_Image_createdAt(ctx, field) + case "storedImages": + return ec.fieldContext_Image_storedImages(ctx, field) + case "MIMEType": + return ec.fieldContext_Image_MIMEType(ctx, field) + case "createdBy": + return ec.fieldContext_Image_createdBy(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Image", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _Image_revisions(ctx context.Context, field graphql.CollectedField, obj *model.Image) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -1964,6 +2065,8 @@ func (ec *executionContext) fieldContext_Image_revisions(_ context.Context, fiel return ec.fieldContext_Image_parent(ctx, field) case "changes": return ec.fieldContext_Image_changes(ctx, field) + case "lineage": + return ec.fieldContext_Image_lineage(ctx, field) case "revisions": return ec.fieldContext_Image_revisions(ctx, field) case "createdAt": @@ -2159,6 +2262,8 @@ func (ec *executionContext) fieldContext_ImageEdge_node(_ context.Context, field return ec.fieldContext_Image_parent(ctx, field) case "changes": return ec.fieldContext_Image_changes(ctx, field) + case "lineage": + return ec.fieldContext_Image_lineage(ctx, field) case "revisions": return ec.fieldContext_Image_revisions(ctx, field) case "createdAt": @@ -3465,6 +3570,8 @@ func (ec *executionContext) fieldContext_Query_viewer(_ context.Context, field g return ec.fieldContext_Viewer_organizationUser(ctx, field) case "organizationUserById": return ec.fieldContext_Viewer_organizationUserById(ctx, field) + case "image": + return ec.fieldContext_Viewer_image(ctx, field) case "images": return ec.fieldContext_Viewer_images(ctx, field) case "hasPermission": @@ -4502,6 +4609,94 @@ func (ec *executionContext) fieldContext_Viewer_organizationUserById(ctx context return fc, nil } +func (ec *executionContext) _Viewer_image(ctx context.Context, field graphql.CollectedField, obj *model.Viewer) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Viewer_image, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Viewer().Image(ctx, obj, fc.Args["id"].(string)) + }, + func(ctx context.Context, next graphql.Resolver) graphql.Resolver { + directive0 := next + + directive1 := func(ctx context.Context) (any, error) { + if ec.Directives.IsAuthenticated == nil { + var zeroVal *model.Image + return zeroVal, errors.New("directive isAuthenticated is not implemented") + } + return ec.Directives.IsAuthenticated(ctx, obj, directive0) + } + + next = directive1 + return next + }, + ec.marshalOImage2ᚖgithubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐImage, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Viewer_image(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Viewer", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Image_id(ctx, field) + case "url": + return ec.fieldContext_Image_url(ctx, field) + case "name": + return ec.fieldContext_Image_name(ctx, field) + case "identifier": + return ec.fieldContext_Image_identifier(ctx, field) + case "nominalWidth": + return ec.fieldContext_Image_nominalWidth(ctx, field) + case "nominalHeight": + return ec.fieldContext_Image_nominalHeight(ctx, field) + case "nominalByteSize": + return ec.fieldContext_Image_nominalByteSize(ctx, field) + case "root": + return ec.fieldContext_Image_root(ctx, field) + case "parent": + return ec.fieldContext_Image_parent(ctx, field) + case "changes": + return ec.fieldContext_Image_changes(ctx, field) + case "lineage": + return ec.fieldContext_Image_lineage(ctx, field) + case "revisions": + return ec.fieldContext_Image_revisions(ctx, field) + case "createdAt": + return ec.fieldContext_Image_createdAt(ctx, field) + case "storedImages": + return ec.fieldContext_Image_storedImages(ctx, field) + case "MIMEType": + return ec.fieldContext_Image_MIMEType(ctx, field) + case "createdBy": + return ec.fieldContext_Image_createdBy(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Image", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Viewer_image_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Viewer_images(ctx context.Context, field graphql.CollectedField, obj *model.Viewer) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -4868,6 +5063,8 @@ func (ec *executionContext) fieldContext_ViewerResult_viewer(_ context.Context, return ec.fieldContext_Viewer_organizationUser(ctx, field) case "organizationUserById": return ec.fieldContext_Viewer_organizationUserById(ctx, field) + case "image": + return ec.fieldContext_Viewer_image(ctx, field) case "images": return ec.fieldContext_Viewer_images(ctx, field) case "hasPermission": @@ -7317,6 +7514,42 @@ func (ec *executionContext) _Image(ctx context.Context, sel ast.SelectionSet, ob continue } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "lineage": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Image_lineage(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "revisions": field := field @@ -8610,6 +8843,39 @@ func (ec *executionContext) _Viewer(ctx context.Context, sel ast.SelectionSet, o continue } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "image": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Viewer_image(ctx, field, obj) + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "images": field := field diff --git a/graph/images.resolvers.go b/graph/images.resolvers.go index 1d726f9..737c89c 100644 --- a/graph/images.resolvers.go +++ b/graph/images.resolvers.go @@ -47,8 +47,17 @@ func (r *imageResolver) URL(ctx context.Context, obj *model.Image) (string, erro // Root is the resolver for the root field. func (r *imageResolver) Root(ctx context.Context, obj *model.Image) (*model.Image, error) { - // TODO: Implement this - return nil, nil + if obj.RootId == "" { + return nil, nil + } + root, err := r.ImageRepo.GetImageById(obj.RootId) + if err != nil { + return nil, err + } + if root == nil { + return nil, nil + } + return model.FromImage(root), nil } // Parent is the resolver for the parent field. @@ -74,6 +83,36 @@ func (r *imageResolver) Changes(ctx context.Context, obj *model.Image) (*string, return &obj.RawChanges, nil } +// Lineage is the resolver for the lineage field. +// Returns the chain of ancestors from root to this image (inclusive). +func (r *imageResolver) Lineage(ctx context.Context, obj *model.Image) ([]*model.Image, error) { + if obj.ParentId == "" { + return []*model.Image{obj}, nil + } + + // Walk up the parent chain collecting ancestors + var chain []*model.Image + chain = append(chain, obj) + currentParentId := obj.ParentId + for currentParentId != "" { + parent, err := r.ImageRepo.GetImageById(currentParentId) + if err != nil { + return nil, err + } + if parent == nil { + break + } + chain = append(chain, model.FromImage(parent)) + currentParentId = parent.ParentId + } + + // Reverse so root comes first + for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 { + chain[i], chain[j] = chain[j], chain[i] + } + return chain, nil +} + // Revisions is the resolver for the revisions field. func (r *imageResolver) Revisions(ctx context.Context, obj *model.Image) ([]*model.Image, error) { // TODO: Implement this method after we support revisions. @@ -96,6 +135,9 @@ func (r *imageResolver) CreatedBy(ctx context.Context, obj *model.Image) (*model // DeleteImage is the resolver for the deleteImage field. func (r *mutationResolver) DeleteImage(ctx context.Context, input model.DeleteImageInput) (*model.DeleteImageResult, error) { + if _, err := uuid.Parse(input.ID); err != nil { + return nil, fmt.Errorf("image not found") + } currentUser := identity.GetCurrentOrganizationUser(r.ContextUserManager, ctx) if img, err := r.ImageRepo.GetImageById(input.ID); err != nil { return nil, err @@ -121,6 +163,14 @@ func (r *mutationResolver) ApplyWatermark(ctx context.Context, input model.Apply return nil, fmt.Errorf("unauthorized") } + // Validate IDs + if _, err := uuid.Parse(input.BaseImageID); err != nil { + return nil, fmt.Errorf("base image not found") + } + if _, err := uuid.Parse(input.OverlayImageID); err != nil { + return nil, fmt.Errorf("overlay image not found") + } + // Validate both images exist and are owned by the current user baseImage, err := r.ImageRepo.GetImageById(input.BaseImageID) if err != nil || baseImage == nil { @@ -239,6 +289,29 @@ func (r *mutationResolver) ApplyWatermark(ctx context.Context, input model.Apply }, nil } +// Image is the resolver for the image field. +func (r *viewerResolver) Image(ctx context.Context, obj *model.Viewer, id string) (*model.Image, error) { + currentUser := identity.GetCurrentOrganizationUser(r.ContextUserManager, ctx) + if currentUser == nil { + return nil, fmt.Errorf("unauthorized") + } + + if _, err := uuid.Parse(id); err != nil { + return nil, fmt.Errorf("image not found") + } + + img, err := r.ImageRepo.GetImageById(id) + if err != nil || img == nil { + return nil, fmt.Errorf("image not found") + } + // TODO: make permission checks more structured. + if img.CreatedById != currentUser.Id && !currentUser.IsSiteOwner() { + return nil, fmt.Errorf("unauthorized") + } + + return model.FromImage(img), nil +} + // Images is the resolver for the images field. func (r *viewerResolver) Images(ctx context.Context, obj *model.Viewer, orderBy *model.ImageOrderByInput, filters *model.ImageFilterInput, after *string, before *string) (*model.ImagesResult, error) { currentUser := identity.GetCurrentOrganizationUser(r.ContextUserManager, ctx) @@ -252,6 +325,7 @@ func (r *viewerResolver) Images(ctx context.Context, obj *model.Viewer, orderBy filters = &model.ImageFilterInput{} } if !currentUser.IsSiteOwner() { + // TODO: make permission checks more structured. if filters.CreatedBy == nil { filters.CreatedBy = ¤tUser.Id } diff --git a/graph/schema/images.graphqls b/graph/schema/images.graphqls index 9fb900b..d6a8767 100644 --- a/graph/schema/images.graphqls +++ b/graph/schema/images.graphqls @@ -15,6 +15,7 @@ type Image { root: Image parent: Image changes: String + lineage: [Image!]! revisions: [Image!]! createdAt: Time! storedImages: [StoredImage!]! @@ -81,6 +82,7 @@ type ImagesResult { } extend type Viewer { + image(id: ID!): Image @isAuthenticated images( orderBy: ImageOrderByInput filters: ImageFilterInput diff --git a/httpserver/image_handlers.go b/httpserver/image_handlers.go index 726c776..597dda3 100644 --- a/httpserver/image_handlers.go +++ b/httpserver/image_handlers.go @@ -23,6 +23,7 @@ import ( ) type UploadReturn struct { + ID string `json:"id"` Filename string `json:"filename"` URL string `json:"url"` Identifier string `json:"identifier"` @@ -162,6 +163,7 @@ func makeUploadHandler( }, } ret := UploadReturn{ + ID: storedImage.Image.Id, Filename: storedImage.Image.Name, URL: image.GetURL(conf.ImageDomain, IsSecure(r), externalImageIdentifiers, conf.DefaultURLFormat), Identifier: storedImage.Image.Identifier, diff --git a/web_client/src/__generated__/gql.ts b/web_client/src/__generated__/gql.ts index ef68244..206da70 100644 --- a/web_client/src/__generated__/gql.ts +++ b/web_client/src/__generated__/gql.ts @@ -20,6 +20,9 @@ type Documents = { "\nmutation resetPassword($input: ResetPasswordInput!) {\n resetPassword(input: $input) {\n success\n }\n}\n": typeof types.ResetPasswordDocument, "\n query ImagesQuery(\n $orderBy: ImageOrderByInput\n $filters: ImageFilterInput\n $after: String\n $before: String\n ) {\n viewer {\n id\n images(\n orderBy: $orderBy\n filters: $filters\n after: $after\n before: $before\n ) {\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n totalCount\n currentCount\n }\n edges {\n cursor\n node {\n id\n url\n name\n nominalWidth\n nominalHeight\n nominalByteSize\n createdAt\n storedImages {\n id\n }\n createdBy {\n id\n user {\n id\n avatarUrl\n }\n }\n }\n }\n }\n }\n }\n": typeof types.ImagesQueryDocument, "\n mutation DeleteImage($input: DeleteImageInput!) {\n deleteImage(input: $input) {\n id\n }\n }\n": typeof types.DeleteImageDocument, + "\n query ImageDetail($id: ID!) {\n viewer {\n id\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n createdAt\n changes\n lineage {\n id\n url\n name\n changes\n createdAt\n }\n }\n }\n }\n": typeof types.ImageDetailDocument, + "\n query ImageForEditor($id: ID!) {\n viewer {\n id\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n": typeof types.ImageForEditorDocument, + "\n mutation ApplyWatermark($input: ApplyWatermarkInput!) {\n applyWatermark(input: $input) {\n image {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n nominalByteSize\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n": typeof types.ApplyWatermarkDocument, "\nquery Auth {\n viewer {\n id\n organizationUser {\n id\n user {\n id\n email\n name\n }\n }\n hasAdminAccess: hasPermission(permission: AdminAccess)\n hasSiteOwnerAccess: hasPermission(permission: SiteOwnerAccess)\n }\n}\n": typeof types.AuthDocument, "\nmutation Logout {\n logout {\n viewer {\n id\n organizationUser {\n id\n user {\n id\n email\n name\n }\n }\n hasAdminAccess: hasPermission(permission: AdminAccess)\n hasSiteOwnerAccess: hasPermission(permission: SiteOwnerAccess)\n }\n }\n}\n": typeof types.LogoutDocument, "\nmutation StorageDefTableConnectivityCellMutation(\n $input: checkStorageDefinitionConnectivityInput!\n ) {\n checkStorageDefinitionConnectivity(input: $input) {\n ok\n error\n }\n }\n": typeof types.StorageDefTableConnectivityCellMutationDocument, @@ -38,6 +41,9 @@ const documents: Documents = { "\nmutation resetPassword($input: ResetPasswordInput!) {\n resetPassword(input: $input) {\n success\n }\n}\n": types.ResetPasswordDocument, "\n query ImagesQuery(\n $orderBy: ImageOrderByInput\n $filters: ImageFilterInput\n $after: String\n $before: String\n ) {\n viewer {\n id\n images(\n orderBy: $orderBy\n filters: $filters\n after: $after\n before: $before\n ) {\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n totalCount\n currentCount\n }\n edges {\n cursor\n node {\n id\n url\n name\n nominalWidth\n nominalHeight\n nominalByteSize\n createdAt\n storedImages {\n id\n }\n createdBy {\n id\n user {\n id\n avatarUrl\n }\n }\n }\n }\n }\n }\n }\n": types.ImagesQueryDocument, "\n mutation DeleteImage($input: DeleteImageInput!) {\n deleteImage(input: $input) {\n id\n }\n }\n": types.DeleteImageDocument, + "\n query ImageDetail($id: ID!) {\n viewer {\n id\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n createdAt\n changes\n lineage {\n id\n url\n name\n changes\n createdAt\n }\n }\n }\n }\n": types.ImageDetailDocument, + "\n query ImageForEditor($id: ID!) {\n viewer {\n id\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n": types.ImageForEditorDocument, + "\n mutation ApplyWatermark($input: ApplyWatermarkInput!) {\n applyWatermark(input: $input) {\n image {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n nominalByteSize\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n": types.ApplyWatermarkDocument, "\nquery Auth {\n viewer {\n id\n organizationUser {\n id\n user {\n id\n email\n name\n }\n }\n hasAdminAccess: hasPermission(permission: AdminAccess)\n hasSiteOwnerAccess: hasPermission(permission: SiteOwnerAccess)\n }\n}\n": types.AuthDocument, "\nmutation Logout {\n logout {\n viewer {\n id\n organizationUser {\n id\n user {\n id\n email\n name\n }\n }\n hasAdminAccess: hasPermission(permission: AdminAccess)\n hasSiteOwnerAccess: hasPermission(permission: SiteOwnerAccess)\n }\n }\n}\n": types.LogoutDocument, "\nmutation StorageDefTableConnectivityCellMutation(\n $input: checkStorageDefinitionConnectivityInput!\n ) {\n checkStorageDefinitionConnectivity(input: $input) {\n ok\n error\n }\n }\n": types.StorageDefTableConnectivityCellMutationDocument, @@ -88,6 +94,18 @@ export function gql(source: "\n query ImagesQuery(\n $orderBy: ImageOrderByI * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function gql(source: "\n mutation DeleteImage($input: DeleteImageInput!) {\n deleteImage(input: $input) {\n id\n }\n }\n"): (typeof documents)["\n mutation DeleteImage($input: DeleteImageInput!) {\n deleteImage(input: $input) {\n id\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n query ImageDetail($id: ID!) {\n viewer {\n id\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n createdAt\n changes\n lineage {\n id\n url\n name\n changes\n createdAt\n }\n }\n }\n }\n"): (typeof documents)["\n query ImageDetail($id: ID!) {\n viewer {\n id\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n createdAt\n changes\n lineage {\n id\n url\n name\n changes\n createdAt\n }\n }\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n query ImageForEditor($id: ID!) {\n viewer {\n id\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n"): (typeof documents)["\n query ImageForEditor($id: ID!) {\n viewer {\n id\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n mutation ApplyWatermark($input: ApplyWatermarkInput!) {\n applyWatermark(input: $input) {\n image {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n nominalByteSize\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n"): (typeof documents)["\n mutation ApplyWatermark($input: ApplyWatermarkInput!) {\n applyWatermark(input: $input) {\n image {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n nominalByteSize\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n"]; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/web_client/src/__generated__/graphql.ts b/web_client/src/__generated__/graphql.ts index c239fce..5a75202 100644 --- a/web_client/src/__generated__/graphql.ts +++ b/web_client/src/__generated__/graphql.ts @@ -17,6 +17,28 @@ export type Scalars = { Time: { input: any; output: any; } }; +export enum Anchor { + BottomLeft = 'BOTTOM_LEFT', + BottomRight = 'BOTTOM_RIGHT', + Center = 'CENTER', + TopLeft = 'TOP_LEFT', + TopRight = 'TOP_RIGHT' +} + +export type ApplyWatermarkInput = { + anchor: Anchor; + baseImageId: Scalars['ID']['input']; + opacity: Scalars['Float']['input']; + overlayImageId: Scalars['ID']['input']; + position: WatermarkPositionInput; + scale: Scalars['Float']['input']; +}; + +export type ApplyWatermarkResult = { + __typename?: 'ApplyWatermarkResult'; + image?: Maybe; +}; + export type CreateUserWithOrganizationInput = { organizationName: Scalars['String']['input']; userEmail: Scalars['String']['input']; @@ -47,14 +69,17 @@ export type IpfsmfsStorageConfig = { export type Image = { __typename?: 'Image'; MIMEType: Scalars['String']['output']; + changes?: Maybe; createdAt: Scalars['Time']['output']; createdBy?: Maybe; id: Scalars['ID']['output']; identifier: Scalars['String']['output']; + lineage: Array; name: Scalars['String']['output']; nominalByteSize: Scalars['Int']['output']; nominalHeight: Scalars['Int']['output']; nominalWidth: Scalars['Int']['output']; + parent?: Maybe; revisions: Array; root?: Maybe; storedImages: Array; @@ -98,6 +123,7 @@ export type ImagesResult = { export type Mutation = { __typename?: 'Mutation'; + applyWatermark: ApplyWatermarkResult; authenticate: ViewerResult; checkStorageDefinitionConnectivity?: Maybe; createStorageDefinition?: Maybe; @@ -110,6 +136,11 @@ export type Mutation = { }; +export type MutationApplyWatermarkArgs = { + input: ApplyWatermarkInput; +}; + + export type MutationAuthenticateArgs = { email: Scalars['String']['input']; organizationId?: InputMaybe; @@ -286,6 +317,7 @@ export type Viewer = { getStorageDefinition?: Maybe; hasPermission: Scalars['Boolean']['output']; id: Scalars['ID']['output']; + image?: Maybe; images: ImagesResult; organizationUser?: Maybe; organizationUserById?: Maybe; @@ -311,6 +343,11 @@ export type ViewerHasPermissionArgs = { }; +export type ViewerImageArgs = { + id: Scalars['ID']['input']; +}; + + export type ViewerImagesArgs = { after?: InputMaybe; before?: InputMaybe; @@ -335,6 +372,11 @@ export type ViewerResult = { viewer: Viewer; }; +export type WatermarkPositionInput = { + x: Scalars['Float']['input']; + y: Scalars['Float']['input']; +}; + export type WebDavStorageConfig = { __typename?: 'WebDAVStorageConfig'; password: Scalars['String']['output']; @@ -408,6 +450,27 @@ export type DeleteImageMutationVariables = Exact<{ export type DeleteImageMutation = { __typename?: 'Mutation', deleteImage: { __typename?: 'DeleteImageResult', id?: string | null } }; +export type ImageDetailQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type ImageDetailQuery = { __typename?: 'Query', viewer: { __typename?: 'Viewer', id: string, image?: { __typename?: 'Image', id: string, url: string, name: string, identifier: string, nominalWidth: number, nominalHeight: number, MIMEType: string, createdAt: any, changes?: string | null, lineage: Array<{ __typename?: 'Image', id: string, url: string, name: string, changes?: string | null, createdAt: any }> } | null } }; + +export type ImageForEditorQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type ImageForEditorQuery = { __typename?: 'Query', viewer: { __typename?: 'Viewer', id: string, image?: { __typename?: 'Image', id: string, url: string, name: string, identifier: string, nominalWidth: number, nominalHeight: number, MIMEType: string, changes?: string | null, parent?: { __typename?: 'Image', id: string, name: string } | null } | null } }; + +export type ApplyWatermarkMutationVariables = Exact<{ + input: ApplyWatermarkInput; +}>; + + +export type ApplyWatermarkMutation = { __typename?: 'Mutation', applyWatermark: { __typename?: 'ApplyWatermarkResult', image?: { __typename?: 'Image', id: string, url: string, name: string, identifier: string, nominalWidth: number, nominalHeight: number, nominalByteSize: number, MIMEType: string, changes?: string | null, parent?: { __typename?: 'Image', id: string, name: string } | null } | null } }; + export type AuthQueryVariables = Exact<{ [key: string]: never; }>; @@ -506,6 +569,9 @@ export const SendResetPasswordEmailDocument = {"kind":"Document","definitions":[ export const ResetPasswordDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"resetPassword"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ResetPasswordInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resetPassword"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode; export const ImagesQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ImagesQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ImageOrderByInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filters"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ImageFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"before"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"viewer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orderBy"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"before"},"value":{"kind":"Variable","name":{"kind":"Name","value":"before"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"currentCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"nominalWidth"}},{"kind":"Field","name":{"kind":"Name","value":"nominalHeight"}},{"kind":"Field","name":{"kind":"Name","value":"nominalByteSize"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"storedImages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const DeleteImageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteImage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteImageInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteImage"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; +export const ImageDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ImageDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"viewer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identifier"}},{"kind":"Field","name":{"kind":"Name","value":"nominalWidth"}},{"kind":"Field","name":{"kind":"Name","value":"nominalHeight"}},{"kind":"Field","name":{"kind":"Name","value":"MIMEType"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"changes"}},{"kind":"Field","name":{"kind":"Name","value":"lineage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"changes"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const ImageForEditorDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ImageForEditor"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"viewer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identifier"}},{"kind":"Field","name":{"kind":"Name","value":"nominalWidth"}},{"kind":"Field","name":{"kind":"Name","value":"nominalHeight"}},{"kind":"Field","name":{"kind":"Name","value":"MIMEType"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"changes"}}]}}]}}]}}]} as unknown as DocumentNode; +export const ApplyWatermarkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ApplyWatermark"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ApplyWatermarkInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applyWatermark"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identifier"}},{"kind":"Field","name":{"kind":"Name","value":"nominalWidth"}},{"kind":"Field","name":{"kind":"Name","value":"nominalHeight"}},{"kind":"Field","name":{"kind":"Name","value":"nominalByteSize"}},{"kind":"Field","name":{"kind":"Name","value":"MIMEType"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"changes"}}]}}]}}]}}]} as unknown as DocumentNode; export const AuthDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Auth"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"viewer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"organizationUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"hasAdminAccess"},"name":{"kind":"Name","value":"hasPermission"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"permission"},"value":{"kind":"EnumValue","value":"AdminAccess"}}]},{"kind":"Field","alias":{"kind":"Name","value":"hasSiteOwnerAccess"},"name":{"kind":"Name","value":"hasPermission"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"permission"},"value":{"kind":"EnumValue","value":"SiteOwnerAccess"}}]}]}}]}}]} as unknown as DocumentNode; export const LogoutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"Logout"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logout"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"viewer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"organizationUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"hasAdminAccess"},"name":{"kind":"Name","value":"hasPermission"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"permission"},"value":{"kind":"EnumValue","value":"AdminAccess"}}]},{"kind":"Field","alias":{"kind":"Name","value":"hasSiteOwnerAccess"},"name":{"kind":"Name","value":"hasPermission"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"permission"},"value":{"kind":"EnumValue","value":"SiteOwnerAccess"}}]}]}}]}}]}}]} as unknown as DocumentNode; export const StorageDefTableConnectivityCellMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"StorageDefTableConnectivityCellMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"checkStorageDefinitionConnectivityInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"checkStorageDefinitionConnectivity"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode; diff --git a/web_client/src/common/ImageGallery/menu.tsx b/web_client/src/common/ImageGallery/menu.tsx index 2a20281..13a7cb7 100644 --- a/web_client/src/common/ImageGallery/menu.tsx +++ b/web_client/src/common/ImageGallery/menu.tsx @@ -5,11 +5,14 @@ import { useDeleteImage } from "./data"; import { copyText } from "~src/lib/copyText"; import { toast } from "react-toastify"; import { absoluteURL } from "~src/lib/url"; +import { routes } from "~src/routes"; import { prompt } from "~src/ui/prompt"; import type { i18n as i18nType } from "i18next"; import { Trans, useTranslation } from "react-i18next"; enum ImageMenuItemName { + DETAILS = "details", + EDIT = "edit", DOWNLOAD = "download", COPY_URL = "copy-url", DELETE = "delete", @@ -23,7 +26,12 @@ export const DEFAULT_MENU_CONFIG: ImageItemMenuConfig = { sections: [ { id: "actions", - names: [ImageMenuItemName.DOWNLOAD, ImageMenuItemName.COPY_URL], + names: [ + ImageMenuItemName.DETAILS, + ImageMenuItemName.EDIT, + ImageMenuItemName.DOWNLOAD, + ImageMenuItemName.COPY_URL, + ], }, { id: "delete", @@ -41,6 +49,8 @@ type MenuItemGetterProps = { type MenuItemGetter = (props: MenuItemGetterProps) => MenuItem; const MENU_ITEM_GETTERS: Record = { + [ImageMenuItemName.DETAILS]: getDetailsMenuItem, + [ImageMenuItemName.EDIT]: getEditMenuItem, [ImageMenuItemName.DOWNLOAD]: getDownloadMenuItem, [ImageMenuItemName.COPY_URL]: getCopyURLMenuItem, [ImageMenuItemName.DELETE]: getDeleteMenuItem, @@ -53,6 +63,32 @@ function getMenuItemByName( return MENU_ITEM_GETTERS[name](props); } +function getDetailsMenuItem({ + image: { id }, + i18n, +}: MenuItemGetterProps): MenuItem { + return { + id: ImageMenuItemName.DETAILS, + children: i18n.t("imageItem.details", "Details"), + action: () => { + window.location.href = routes.profile.image(id); + }, + }; +} + +function getEditMenuItem({ + image: { id }, + i18n, +}: MenuItemGetterProps): MenuItem { + return { + id: ImageMenuItemName.EDIT, + children: i18n.t("imageItem.edit", "Edit"), + action: () => { + window.location.href = routes.profile.editImage(id); + }, + }; +} + function getDownloadMenuItem({ image: { url }, i18n, diff --git a/web_client/src/editor/EditorCanvas.tsx b/web_client/src/editor/EditorCanvas.tsx new file mode 100644 index 0000000..4608b76 --- /dev/null +++ b/web_client/src/editor/EditorCanvas.tsx @@ -0,0 +1,186 @@ +import React from "react"; +import { Anchor } from "~src/__generated__/graphql"; + +export type OverlayState = { + image: HTMLImageElement | null; + x: number; // 0-1 normalized + y: number; // 0-1 normalized + opacity: number; // 0-1 + scale: number; // 0-1, relative to base short side + anchor: Anchor; +}; + +type EditorCanvasProps = { + baseImageUrl: string; + overlay: OverlayState; + onPositionChange: (x: number, y: number) => void; + className?: string; +}; + +export function EditorCanvas({ + baseImageUrl, + overlay, + onPositionChange, + className, +}: EditorCanvasProps) { + const canvasRef = React.useRef(null); + const baseImgRef = React.useRef(null); + const [baseLoaded, setBaseLoaded] = React.useState(false); + const [dragging, setDragging] = React.useState(false); + + // Load base image + React.useEffect(() => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + baseImgRef.current = img; + setBaseLoaded(true); + }; + img.src = baseImageUrl; + }, [baseImageUrl]); + + // Draw canvas + React.useEffect(() => { + const canvas = canvasRef.current; + const baseImg = baseImgRef.current; + if (!canvas || !baseImg || !baseLoaded) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Size canvas to fit container while maintaining aspect ratio + const container = canvas.parentElement; + if (!container) return; + const maxW = container.clientWidth; + const ratio = baseImg.naturalWidth / baseImg.naturalHeight; + const displayW = Math.min(maxW, baseImg.naturalWidth); + const displayH = displayW / ratio; + + canvas.width = displayW; + canvas.height = displayH; + + // Draw base + ctx.clearRect(0, 0, displayW, displayH); + ctx.drawImage(baseImg, 0, 0, displayW, displayH); + + // Draw overlay + if (overlay.image) { + const shortSide = Math.min(displayW, displayH); + const targetSize = shortSide * overlay.scale; + const overlayRatio = + overlay.image.naturalWidth / overlay.image.naturalHeight; + let ow: number, oh: number; + if (overlayRatio >= 1) { + ow = targetSize; + oh = targetSize / overlayRatio; + } else { + oh = targetSize; + ow = targetSize * overlayRatio; + } + + const px = overlay.x * displayW; + const py = overlay.y * displayH; + + // Apply anchor offset to match backend behavior + let ox: number, oy: number; + switch (overlay.anchor) { + case Anchor.TopLeft: + ox = 0; + oy = 0; + break; + case Anchor.TopRight: + ox = -ow; + oy = 0; + break; + case Anchor.BottomLeft: + ox = 0; + oy = -oh; + break; + case Anchor.BottomRight: + ox = -ow; + oy = -oh; + break; + case Anchor.Center: + default: + ox = -ow / 2; + oy = -oh / 2; + break; + } + + ctx.globalAlpha = overlay.opacity; + ctx.drawImage(overlay.image, px + ox, py + oy, ow, oh); + ctx.globalAlpha = 1; + } + }, [baseLoaded, overlay]); + + const getCanvasCoords = React.useCallback( + (e: React.MouseEvent | React.Touch) => { + const canvas = canvasRef.current; + if (!canvas) return { x: 0, y: 0 }; + const rect = canvas.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); + return { x, y }; + }, + [], + ); + + const handleMouseDown = React.useCallback( + (e: React.MouseEvent) => { + if (!overlay.image) return; + setDragging(true); + const { x, y } = getCanvasCoords(e); + onPositionChange(x, y); + }, + [overlay.image, getCanvasCoords, onPositionChange], + ); + + const handleMouseMove = React.useCallback( + (e: React.MouseEvent) => { + if (!dragging) return; + const { x, y } = getCanvasCoords(e); + onPositionChange(x, y); + }, + [dragging, getCanvasCoords, onPositionChange], + ); + + const handleMouseUp = React.useCallback(() => { + setDragging(false); + }, []); + + const handleTouchStart = React.useCallback( + (e: React.TouchEvent) => { + if (!overlay.image || e.touches.length === 0) return; + e.preventDefault(); + setDragging(true); + const { x, y } = getCanvasCoords(e.touches[0]); + onPositionChange(x, y); + }, + [overlay.image, getCanvasCoords, onPositionChange], + ); + + const handleTouchMove = React.useCallback( + (e: React.TouchEvent) => { + if (!dragging || e.touches.length === 0) return; + e.preventDefault(); + const { x, y } = getCanvasCoords(e.touches[0]); + onPositionChange(x, y); + }, + [dragging, getCanvasCoords, onPositionChange], + ); + + return ( + + ); +} diff --git a/web_client/src/editor/ImageDetail.tsx b/web_client/src/editor/ImageDetail.tsx new file mode 100644 index 0000000..e0a3779 --- /dev/null +++ b/web_client/src/editor/ImageDetail.tsx @@ -0,0 +1,270 @@ +import React from "react"; +import { useParams, useNavigate, Link } from "react-router"; +import { useLazyQuery } from "@apollo/client/react"; +import { gql } from "~src/__generated__"; +import classNames from "classnames"; +import { + HEADING_2, + SECOND_LAYER, + TEXT_COLOR, + SECONDARY_TEXT_COLOR_DIM, + BASE_LAYER, +} from "~src/ui/classNames"; +import { FullScreenLoader } from "~src/ui/fullscreenLoader"; +import { Button } from "~src/ui/button"; +import { absoluteURL } from "~src/lib/url"; +import { routes } from "~src/routes"; +import { copyText } from "~src/lib/copyText"; +import { useTranslation } from "react-i18next"; + +const ImageDetailDoc = gql(` + query ImageDetail($id: ID!) { + viewer { + id + image(id: $id) { + id + url + name + identifier + nominalWidth + nominalHeight + MIMEType + createdAt + changes + lineage { + id + url + name + changes + createdAt + } + } + } + } +`); + +export function ImageDetail() { + const { imageId } = useParams<{ imageId: string }>(); + const navigate = useNavigate(); + const [fetchImage, { data, loading, error }] = useLazyQuery(ImageDetailDoc); + + React.useEffect(() => { + if (imageId) { + fetchImage({ variables: { id: imageId } }); + } + }, [imageId, fetchImage]); + + const image = data?.viewer.image; + + if (loading) return ; + if (error) + return ( +
+ Error loading image: {error.message} +
+ ); + if (!image) + return
Image not found
; + + const lineage = image.lineage; + const currentIndex = lineage.findIndex((img) => img.id === image.id); + + return ( +
+
+

+ {image.name} +

+
+ + +
+
+ +
+
+
+ {image.name} +
+
+ {image.nominalWidth} x {image.nominalHeight} px ·{" "} + {image.MIMEType} +
+ +
+ + {lineage.length > 1 && ( +
+
+

+ Edit History +

+
+ {lineage.map((ancestor, i) => { + const isCurrent = i === currentIndex; + const changeSet = ancestor.changes + ? parseChangeType(ancestor.changes) + : null; + return ( +
+ {ancestor.name} +
+ {isCurrent ? ( + + {ancestor.name} + + ) : ( + + {ancestor.name} + + )} + + {i === 0 + ? "Original" + : changeSet + ? changeSet + : "Edit"} + +
+
+ ); + })} +
+
+
+ )} +
+
+ ); +} + +function CopyURLs({ url, name }: { url: string; name: string }) { + const formats = React.useMemo( + () => [ + { label: "URL", value: url }, + { label: "HTML", value: `${name}` }, + { label: "Markdown", value: `![${name}](${url})` }, + { label: "BBCode", value: `[img]${url}[/img]` }, + ], + [url, name], + ); + + return ( +
+

+ Copy References +

+
+ {formats.map((fmt) => ( + + ))} +
+
+ ); +} + +function CopyRow({ label, value }: { label: string; value: string }) { + const { t } = useTranslation(); + const [justCopied, setJustCopied] = React.useState(false); + const timeoutRef = React.useRef | undefined>( + undefined, + ); + + const handleCopy = React.useCallback(() => { + copyText(value, () => { + setJustCopied(true); + clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setJustCopied(false), 1500); + }); + }, [value]); + + React.useEffect(() => { + return () => clearTimeout(timeoutRef.current); + }, []); + + return ( +
+ + {label} + + + {value} + + +
+ ); +} + +function parseChangeType(changesJson: string): string | null { + try { + const parsed = JSON.parse(changesJson); + if (parsed.type) { + return parsed.type.charAt(0).toUpperCase() + parsed.type.slice(1); + } + } catch { + // ignore + } + return null; +} diff --git a/web_client/src/editor/ImageEditor.tsx b/web_client/src/editor/ImageEditor.tsx new file mode 100644 index 0000000..da73d65 --- /dev/null +++ b/web_client/src/editor/ImageEditor.tsx @@ -0,0 +1,199 @@ +import React from "react"; +import { useParams, useNavigate } from "react-router"; +import { useLazyQuery } from "@apollo/client/react"; +import { gql } from "~src/__generated__"; +import { Anchor } from "~src/__generated__/graphql"; +import { EditorCanvas, OverlayState } from "./EditorCanvas"; +import { WatermarkTool, WatermarkSettings } from "./WatermarkTool"; +import { useApplyWatermark } from "./data"; +import { toast } from "react-toastify"; +import classNames from "classnames"; +import { + HEADING_2, + SECOND_LAYER, + TEXT_COLOR, + SECONDARY_TEXT_COLOR_DIM, +} from "~src/ui/classNames"; +import { FullScreenLoader } from "~src/ui/fullscreenLoader"; +import { Button } from "~src/ui/button"; +import { absoluteURL } from "~src/lib/url"; +import { routes } from "~src/routes"; + +const ImageForEditorDoc = gql(` + query ImageForEditor($id: ID!) { + viewer { + id + image(id: $id) { + id + url + name + identifier + nominalWidth + nominalHeight + MIMEType + parent { + id + name + } + changes + } + } + } +`); + +export function ImageEditor() { + const { imageId } = useParams<{ imageId: string }>(); + const navigate = useNavigate(); + const [fetchImage, { data, loading, error }] = + useLazyQuery(ImageForEditorDoc); + const { execute: applyWatermark, loading: applying } = useApplyWatermark(); + + const [settings, setSettings] = React.useState({ + overlayImageId: "", + overlayImageUrl: "", + opacity: 0.5, + scale: 0.25, + anchor: Anchor.Center, + positionX: 0.5, + positionY: 0.5, + }); + + const [overlayImg, setOverlayImg] = React.useState( + null, + ); + + React.useEffect(() => { + if (imageId) { + fetchImage({ variables: { id: imageId } }); + } + }, [imageId, fetchImage]); + + // Load overlay image element when URL changes + React.useEffect(() => { + if (!settings.overlayImageUrl) { + return; + } + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => setOverlayImg(img); + img.src = absoluteURL(settings.overlayImageUrl); + return () => { + setOverlayImg(null); + }; + }, [settings.overlayImageUrl]); + + const image = data?.viewer.image; + + const overlay: OverlayState = React.useMemo( + () => ({ + image: overlayImg, + x: settings.positionX, + y: settings.positionY, + opacity: settings.opacity, + scale: settings.scale, + anchor: settings.anchor, + }), + [ + overlayImg, + settings.positionX, + settings.positionY, + settings.opacity, + settings.scale, + settings.anchor, + ], + ); + + const handlePositionChange = React.useCallback((x: number, y: number) => { + setSettings((prev) => ({ ...prev, positionX: x, positionY: y })); + }, []); + + const handleApply = React.useCallback(async () => { + if (!imageId || !settings.overlayImageId) return; + try { + const result = await applyWatermark({ + variables: { + input: { + baseImageId: imageId, + overlayImageId: settings.overlayImageId, + position: { + x: settings.positionX, + y: settings.positionY, + }, + anchor: settings.anchor, + opacity: settings.opacity, + scale: settings.scale, + }, + }, + }); + const newImage = result.data?.applyWatermark.image; + if (newImage) { + toast("Watermark applied successfully"); + navigate(routes.profile.image(newImage.id), { replace: true }); + } + } catch (_err) { + toast.error("Failed to apply watermark"); + } + }, [imageId, settings, applyWatermark, navigate]); + + if (loading) return ; + if (error) + return ( +
+ Error loading image: {error.message} +
+ ); + if (!image) + return
Image not found
; + + return ( +
+
+

+ Edit: {image.name} +

+ +
+ + {image.parent && ( +

+ Derived from: {image.parent.name} +

+ )} + +
+
+
+ +
+
+ +
+
+

+ Watermark +

+ +
+
+
+
+ ); +} diff --git a/web_client/src/editor/WatermarkTool.tsx b/web_client/src/editor/WatermarkTool.tsx new file mode 100644 index 0000000..457776b --- /dev/null +++ b/web_client/src/editor/WatermarkTool.tsx @@ -0,0 +1,223 @@ +import React from "react"; +import { Anchor } from "~src/__generated__/graphql"; +import { Button } from "~src/ui/button"; +import { Input, InputWithLabel } from "~src/ui/input"; +import { SelectWithLabel } from "~src/ui/select"; +import { ImageGallery } from "~src/common/ImageGallery/render"; +import { useAuth } from "~src/lib/auth"; +import { RenderingImageItem } from "~src/common/ImageGallery/types"; +import classNames from "classnames"; +import { TEXT_COLOR, SECONDARY_TEXT_COLOR_DIM } from "~src/ui/classNames"; + +export type WatermarkSettings = { + overlayImageId: string; + overlayImageUrl: string; + opacity: number; + scale: number; + anchor: Anchor; + positionX: number; + positionY: number; +}; + +type WatermarkToolProps = { + baseImageId: string; + settings: WatermarkSettings; + onSettingsChange: (settings: WatermarkSettings) => void; + onApply: () => void; + applying: boolean; +}; + +const ANCHOR_LABELS: Record = { + [Anchor.TopLeft]: "Top Left", + [Anchor.TopRight]: "Top Right", + [Anchor.BottomLeft]: "Bottom Left", + [Anchor.BottomRight]: "Bottom Right", + [Anchor.Center]: "Center", +}; + +export function WatermarkTool({ + baseImageId, + settings, + onSettingsChange, + onApply, + applying, +}: WatermarkToolProps) { + const [showPicker, setShowPicker] = React.useState(false); + const { data: authData } = useAuth(); + const userId = authData?.viewer.organizationUser?.id; + + const handleSelectOverlay = React.useCallback( + (image: RenderingImageItem) => { + if (image.id === baseImageId) return; + onSettingsChange({ + ...settings, + overlayImageId: image.id, + overlayImageUrl: image.url, + }); + setShowPicker(false); + }, + [baseImageId, settings, onSettingsChange], + ); + + const overlayItemRenderer = React.useCallback( + (image: RenderingImageItem) => { + const isBase = image.id === baseImageId; + const isSelected = image.id === settings.overlayImageId; + return ( + + ); + }, + [baseImageId, settings.overlayImageId, handleSelectOverlay], + ); + + return ( +
+
+ + {settings.overlayImageUrl ? ( +
+ overlay + +
+ ) : ( + + )} +
+ + {showPicker && userId && ( + + )} + + {settings.overlayImageId && ( + <> + + onSettingsChange({ + ...settings, + opacity: parseFloat(e.target.value), + }) + } + /> + + + onSettingsChange({ + ...settings, + scale: parseFloat(e.target.value), + }) + } + /> + + + onSettingsChange({ + ...settings, + anchor: e.target.value as Anchor, + }) + } + > + {Object.entries(ANCHOR_LABELS).map(([value, label]) => ( + + ))} + + + + + )} +
+ ); +} + +function OverlayPicker({ + userId, + itemRenderer, +}: { + userId: string; + itemRenderer: (image: RenderingImageItem) => React.ReactNode; +}) { + const [search, setSearch] = React.useState(""); + const [debouncedSearch, setDebouncedSearch] = React.useState(""); + + React.useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(search), 300); + return () => clearTimeout(timer); + }, [search]); + + return ( +
+ setSearch(e.target.value)} + className="mb-2 w-full" + /> +

+ Select an image to use as watermark (base image is dimmed) +

+ +
+ ); +} diff --git a/web_client/src/editor/data.tsx b/web_client/src/editor/data.tsx new file mode 100644 index 0000000..8a16332 --- /dev/null +++ b/web_client/src/editor/data.tsx @@ -0,0 +1,29 @@ +import { useMutation } from "@apollo/client/react"; +import { gql } from "~src/__generated__"; + +const ApplyWatermarkDoc = gql(` + mutation ApplyWatermark($input: ApplyWatermarkInput!) { + applyWatermark(input: $input) { + image { + id + url + name + identifier + nominalWidth + nominalHeight + nominalByteSize + MIMEType + parent { + id + name + } + changes + } + } + } +`); + +export function useApplyWatermark() { + const [execute, { loading, error, data }] = useMutation(ApplyWatermarkDoc); + return { execute, loading, error, data }; +} diff --git a/web_client/src/entry.tsx b/web_client/src/entry.tsx index b651ea3..7324b43 100644 --- a/web_client/src/entry.tsx +++ b/web_client/src/entry.tsx @@ -103,12 +103,34 @@ const router = createBrowserRouter([ element: , }, { - path: `${routeSegments.images}/*`, + path: routeSegments.images, + element: , + }, + { + path: `${routeSegments.images}/list`, lazy: async () => { - const { Images } = - await import("~src/profile/pages/images/imagesIndex"); + const { ListImages } = + await import("~src/profile/pages/images/listImages"); return { - element: , + element: , + }; + }, + }, + { + path: `${routeSegments.images}/:imageId`, + lazy: async () => { + const { ImageDetail } = await import("~src/editor/ImageDetail"); + return { + element: , + }; + }, + }, + { + path: `${routeSegments.images}/:imageId/edit`, + lazy: async () => { + const { ImageEditor } = await import("~src/editor/ImageEditor"); + return { + element: , }; }, }, diff --git a/web_client/src/routes.ts b/web_client/src/routes.ts index 8523a78..3889d94 100644 --- a/web_client/src/routes.ts +++ b/web_client/src/routes.ts @@ -17,5 +17,9 @@ export const routes = { profile: { root: `/${routeSegments.profile}`, images: `/${routeSegments.profile}/${routeSegments.images}`, + image: (imageId: string) => + `/${routeSegments.profile}/${routeSegments.images}/${imageId}`, + editImage: (imageId: string) => + `/${routeSegments.profile}/${routeSegments.images}/${imageId}/edit`, }, }; diff --git a/web_client/src/uploader/uploader.tsx b/web_client/src/uploader/uploader.tsx index 51d6748..c1342a4 100644 --- a/web_client/src/uploader/uploader.tsx +++ b/web_client/src/uploader/uploader.tsx @@ -15,6 +15,9 @@ import { useTranslation } from "react-i18next"; import { addSessionHeaderToXMLHttpRequest } from "~src/lib/sessionToken"; import { absoluteURL } from "~src/lib/url"; import { track } from "~src/lib/analytics"; +import { useAuth } from "~src/lib/auth"; +import { routes } from "~src/routes"; +import { useNavigate } from "react-router"; type UploadSource = "file_input" | "drag_drop" | "paste"; @@ -30,10 +33,14 @@ export type UploadingFile = { aborted: boolean; uploadedFileName?: string; url?: string; + imageId?: string; }; export function Uplodaer() { const { t } = useTranslation(); + const { data: authData } = useAuth(); + const isAuthenticated = !!authData?.viewer.organizationUser; + const navigate = useNavigate(); const [uploadingFiles, setUploadingFiles] = React.useState( [], ); @@ -79,10 +86,13 @@ export function Uplodaer() { const resp = request.responseText; let filename: string; let url: string; + let imageId: string | undefined; try { - const payload: { filename: string; url: string } = JSON.parse(resp); + const payload: { id?: string; filename: string; url: string } = + JSON.parse(resp); filename = payload.filename; url = payload.url; + imageId = payload.id; } catch { setCurrentFile({ errored: true }); track("upload_failed", { ...baseProps, reason: "bad_response" }); @@ -96,6 +106,7 @@ export function Uplodaer() { loaded: true, uploadedFileName: filename, url, + imageId, }); track("upload_succeeded", baseProps); } else { @@ -188,6 +199,15 @@ export function Uplodaer() { React.useEffect(() => { window.document.body.focus(); }, []); + + // Navigate to detail page when authenticated user uploads a single image + React.useEffect(() => { + if (!isAuthenticated || uploadingFiles.length !== 1) return; + const file = uploadingFiles[0]; + if (file.loaded && file.imageId) { + navigate(routes.profile.image(file.imageId)); + } + }, [isAuthenticated, uploadingFiles, navigate]); const handleDrop = React.useCallback( (e: React.DragEvent) => { e.preventDefault(); From 7dcdc5e3864e8fce4d14efa254e11dea289d2a64 Mon Sep 17 00:00:00 2001 From: shen Date: Mon, 1 Jun 2026 02:29:38 -0400 Subject: [PATCH 03/15] more tests; fill a few gaps --- editing/fetch.go | 7 +- editing/watermark_test.go | 100 ++++++++++++ graph/images.resolvers.go | 19 ++- graph/images.resolvers_test.go | 207 ++++++++++++++++++++++++ web_client/src/editor/ImageDetail.tsx | 24 +-- web_client/src/editor/ImageEditor.tsx | 24 +-- web_client/src/editor/WatermarkTool.tsx | 43 +++-- web_client/src/localization/en.json | 42 ++++- web_client/src/profile/layout.tsx | 9 +- 9 files changed, 435 insertions(+), 40 deletions(-) diff --git a/editing/fetch.go b/editing/fetch.go index 208ebea..73bad7a 100644 --- a/editing/fetch.go +++ b/editing/fetch.go @@ -71,10 +71,15 @@ func NewFetchImageFunc( } defer reader.Close() - data, err := io.ReadAll(reader) + const maxImageBytes = 10 * 1024 * 1024 // 10 MB + limitedReader := io.LimitReader(reader, maxImageBytes+1) + data, err := io.ReadAll(limitedReader) if err != nil { return nil, fmt.Errorf("failed to read image data: %w", err) } + if len(data) > maxImageBytes { + return nil, fmt.Errorf("image exceeds maximum size of %d bytes", maxImageBytes) + } return data, nil } } diff --git a/editing/watermark_test.go b/editing/watermark_test.go index 5066f32..2f6e4c9 100644 --- a/editing/watermark_test.go +++ b/editing/watermark_test.go @@ -3,6 +3,7 @@ package editing import ( "bytes" "encoding/json" + "fmt" "image" "image/color" "image/png" @@ -187,3 +188,102 @@ func TestWatermarkCornerPositions(t *testing.T) { t.Fatal("expected bottom-right corner to have overlay color") } } + +func TestWatermarkNegativeOpacity(t *testing.T) { + base := makeTestPNG(100, 100, color.White) + overlay := makeTestPNG(10, 10, color.Black) + + cs := makeParams("o", 0.5, 0.5, AnchorCenter, -0.5, 0.1) + editor := &WatermarkEditor{} + _, _, err := editor.Apply(base, cs, func(id string) ([]byte, error) { + return overlay, nil + }) + if err == nil { + t.Fatal("expected error for negative opacity") + } +} + +func TestWatermarkInvalidBaseImage(t *testing.T) { + base := []byte("not a valid image") + overlay := makeTestPNG(10, 10, color.Black) + + cs := makeParams("o", 0.5, 0.5, AnchorCenter, 0.5, 0.1) + editor := &WatermarkEditor{} + _, _, err := editor.Apply(base, cs, func(id string) ([]byte, error) { + return overlay, nil + }) + if err == nil { + t.Fatal("expected error for invalid base image") + } +} + +func TestWatermarkInvalidOverlayImage(t *testing.T) { + base := makeTestPNG(100, 100, color.White) + + cs := makeParams("bad", 0.5, 0.5, AnchorCenter, 0.5, 0.1) + editor := &WatermarkEditor{} + _, _, err := editor.Apply(base, cs, func(id string) ([]byte, error) { + return []byte("not a valid image"), nil + }) + if err == nil { + t.Fatal("expected error for invalid overlay image") + } +} + +func TestWatermarkFetchFailure(t *testing.T) { + base := makeTestPNG(100, 100, color.White) + + cs := makeParams("missing", 0.5, 0.5, AnchorCenter, 0.5, 0.1) + editor := &WatermarkEditor{} + _, _, err := editor.Apply(base, cs, func(id string) ([]byte, error) { + return nil, fmt.Errorf("image not found") + }) + if err == nil { + t.Fatal("expected error when fetch fails") + } +} + +func TestWatermarkInvalidJSON(t *testing.T) { + base := makeTestPNG(100, 100, color.White) + + cs := ChangeSet{Type: "watermark", Params: []byte("not json")} + editor := &WatermarkEditor{} + _, _, err := editor.Apply(base, cs, func(id string) ([]byte, error) { + return base, nil + }) + if err == nil { + t.Fatal("expected error for invalid JSON params") + } +} + +func TestWatermarkScaleGreaterThanOne(t *testing.T) { + base := makeTestPNG(100, 100, color.White) + overlay := makeTestPNG(10, 10, color.Black) + + cs := makeParams("o", 0.5, 0.5, AnchorCenter, 0.5, 1.5) + editor := &WatermarkEditor{} + _, _, err := editor.Apply(base, cs, func(id string) ([]byte, error) { + return overlay, nil + }) + if err == nil { + t.Fatal("expected error for scale > 1") + } +} + +func TestWatermarkExtremeScale(t *testing.T) { + base := makeTestPNG(100, 100, color.White) + overlay := makeTestPNG(10, 10, color.RGBA{0, 0, 255, 255}) + + // Scale at maximum (1.0) should still work + cs := makeParams("o", 0.5, 0.5, AnchorCenter, 0.5, 1.0) + editor := &WatermarkEditor{} + result, _, err := editor.Apply(base, cs, func(id string) ([]byte, error) { + return overlay, nil + }) + if err != nil { + t.Fatal(err) + } + if len(result) == 0 { + t.Fatal("expected non-empty result") + } +} diff --git a/graph/images.resolvers.go b/graph/images.resolvers.go index 737c89c..9da96ee 100644 --- a/graph/images.resolvers.go +++ b/graph/images.resolvers.go @@ -90,11 +90,12 @@ func (r *imageResolver) Lineage(ctx context.Context, obj *model.Image) ([]*model return []*model.Image{obj}, nil } - // Walk up the parent chain collecting ancestors + // Walk up the parent chain collecting ancestors (max 100 to guard against cycles) + const maxDepth = 100 var chain []*model.Image chain = append(chain, obj) currentParentId := obj.ParentId - for currentParentId != "" { + for currentParentId != "" && len(chain) < maxDepth { parent, err := r.ImageRepo.GetImageById(currentParentId) if err != nil { return nil, err @@ -184,6 +185,20 @@ func (r *mutationResolver) ApplyWatermark(ctx context.Context, input model.Apply if err != nil || overlayImage == nil { return nil, fmt.Errorf("overlay image not found") } + if overlayImage.CreatedById != currentUser.Id && !currentUser.IsSiteOwner() { + return nil, fmt.Errorf("unauthorized") + } + + // Validate input ranges + if input.Position.X < 0 || input.Position.X > 1 || input.Position.Y < 0 || input.Position.Y > 1 { + return nil, fmt.Errorf("position values must be between 0 and 1") + } + if input.Opacity < 0 || input.Opacity > 1 { + return nil, fmt.Errorf("opacity must be between 0 and 1") + } + if input.Scale <= 0 || input.Scale > 1 { + return nil, fmt.Errorf("scale must be between 0 (exclusive) and 1") + } // Map GraphQL anchor to editing anchor anchorMap := map[model.Anchor]editing.Anchor{ diff --git a/graph/images.resolvers_test.go b/graph/images.resolvers_test.go index 92614d8..b88e827 100644 --- a/graph/images.resolvers_test.go +++ b/graph/images.resolvers_test.go @@ -672,6 +672,208 @@ func tApplyWatermarkInvalidImageId(t *testing.T, tc *TestContext) { require.Error(t, err) } +func tViewerImage(t *testing.T, tc *TestContext) { + orgUser := tc.forceAuthenticate() + sd, tempDir := createFSStorageDefinition(t, tc) + defer os.RemoveAll(tempDir) + + imgBytes := makeTestPNGBytes(100, 100, color.RGBA{255, 0, 0, 255}) + img := createRealImage(t, tc, orgUser.Id, sd.Id, imgBytes) + + var resp struct { + Viewer struct { + Image *struct { + ID string + Name string + MIMEType string + } + } + } + + err := tc.client.Post(` + query viewerImage($id: ID!) { + viewer { + image(id: $id) { + id + name + MIMEType + } + } + }`, &resp, client.Var("id", img.Id)) + require.NoError(t, err) + require.NotNil(t, resp.Viewer.Image) + require.Equal(t, img.Id, resp.Viewer.Image.ID) + require.Equal(t, img.Name, resp.Viewer.Image.Name) +} + +func tViewerImageUnauthorized(t *testing.T, tc *TestContext) { + orgUser1 := tc.forceAuthenticate() + orgUser2 := tc.forceAuthenticate() + sd, tempDir := createFSStorageDefinition(t, tc) + defer os.RemoveAll(tempDir) + + imgBytes := makeTestPNGBytes(100, 100, color.White) + img := createRealImage(t, tc, orgUser1.Id, sd.Id, imgBytes) + + // Authenticate as orgUser2 and try to access orgUser1's image + tc.setAuthenticatedUser(orgUser2) + + var resp struct { + Viewer struct { + Image *struct{ ID string } + } + } + + err := tc.client.Post(` + query viewerImage($id: ID!) { + viewer { + image(id: $id) { + id + } + } + }`, &resp, client.Var("id", img.Id)) + require.Error(t, err) +} + +func tViewerImageInvalidId(t *testing.T, tc *TestContext) { + tc.forceAuthenticate() + + var resp struct { + Viewer struct { + Image *struct{ ID string } + } + } + + err := tc.client.Post(` + query viewerImage($id: ID!) { + viewer { + image(id: $id) { + id + } + } + }`, &resp, client.Var("id", "not-a-uuid")) + require.Error(t, err) +} + +func tImageLineageAndRoot(t *testing.T, tc *TestContext) { + orgUser := tc.forceAuthenticate() + sd, tempDir := createFSStorageDefinition(t, tc) + defer os.RemoveAll(tempDir) + + baseBytes := makeTestPNGBytes(200, 200, color.RGBA{255, 0, 0, 255}) + overlayBytes := makeTestPNGBytes(50, 50, color.RGBA{0, 0, 255, 255}) + baseImage := createRealImage(t, tc, orgUser.Id, sd.Id, baseBytes) + overlayImage := createRealImage(t, tc, orgUser.Id, sd.Id, overlayBytes) + + // Apply watermark to create a child image + var applyResp struct { + ApplyWatermark *struct { + Image *struct{ ID string } + } + } + err := tc.client.Post(` + mutation applyWatermark($input: ApplyWatermarkInput!) { + applyWatermark(input: $input) { + image { id } + } + }`, &applyResp, client.Var("input", map[string]interface{}{ + "baseImageId": baseImage.Id, + "overlayImageId": overlayImage.Id, + "position": map[string]float64{"x": 0.5, "y": 0.5}, + "anchor": "CENTER", + "opacity": 0.5, + "scale": 0.2, + })) + require.NoError(t, err) + childId := applyResp.ApplyWatermark.Image.ID + + // Query the child image for lineage and root + var resp struct { + Viewer struct { + Image *struct { + ID string + Root *struct { + ID string + } + Lineage []struct { + ID string + Changes *string + } + } + } + } + err = tc.client.Post(` + query viewerImage($id: ID!) { + viewer { + image(id: $id) { + id + root { id } + lineage { + id + changes + } + } + } + }`, &resp, client.Var("id", childId)) + require.NoError(t, err) + require.NotNil(t, resp.Viewer.Image) + + // Root should be the base image + require.NotNil(t, resp.Viewer.Image.Root) + require.Equal(t, baseImage.Id, resp.Viewer.Image.Root.ID) + + // Lineage should be [baseImage, childImage] + require.Len(t, resp.Viewer.Image.Lineage, 2) + require.Equal(t, baseImage.Id, resp.Viewer.Image.Lineage[0].ID) + require.Equal(t, childId, resp.Viewer.Image.Lineage[1].ID) + + // First in lineage (root) should have no changes + require.Nil(t, resp.Viewer.Image.Lineage[0].Changes) + // Second (child) should have watermark changes + require.NotNil(t, resp.Viewer.Image.Lineage[1].Changes) + require.Contains(t, *resp.Viewer.Image.Lineage[1].Changes, "watermark") +} + +func tImageNoParentLineage(t *testing.T, tc *TestContext) { + orgUser := tc.forceAuthenticate() + sd, tempDir := createFSStorageDefinition(t, tc) + defer os.RemoveAll(tempDir) + + imgBytes := makeTestPNGBytes(100, 100, color.White) + img := createRealImage(t, tc, orgUser.Id, sd.Id, imgBytes) + + var resp struct { + Viewer struct { + Image *struct { + ID string + Root *struct { + ID string + } + Lineage []struct{ ID string } + } + } + } + err := tc.client.Post(` + query viewerImage($id: ID!) { + viewer { + image(id: $id) { + id + root { id } + lineage { id } + } + } + }`, &resp, client.Var("id", img.Id)) + require.NoError(t, err) + require.NotNil(t, resp.Viewer.Image) + + // Root should be nil for an original image + require.Nil(t, resp.Viewer.Image.Root) + + // Lineage should be just the image itself + require.Len(t, resp.Viewer.Image.Lineage, 1) + require.Equal(t, img.Id, resp.Viewer.Image.Lineage[0].ID) +} + func TestImageResolvers(t *testing.T) { tc := newTestContext(t) tc.runTestCases( @@ -687,5 +889,10 @@ func TestImageResolvers(t *testing.T) { tApplyWatermarkUnauthenticated, tApplyWatermarkUnauthorizedImage, tApplyWatermarkInvalidImageId, + tViewerImage, + tViewerImageUnauthorized, + tViewerImageInvalidId, + tImageLineageAndRoot, + tImageNoParentLineage, ) } diff --git a/web_client/src/editor/ImageDetail.tsx b/web_client/src/editor/ImageDetail.tsx index e0a3779..79d582e 100644 --- a/web_client/src/editor/ImageDetail.tsx +++ b/web_client/src/editor/ImageDetail.tsx @@ -46,6 +46,7 @@ const ImageDetailDoc = gql(` export function ImageDetail() { const { imageId } = useParams<{ imageId: string }>(); const navigate = useNavigate(); + const { t } = useTranslation(); const [fetchImage, { data, loading, error }] = useLazyQuery(ImageDetailDoc); React.useEffect(() => { @@ -60,11 +61,15 @@ export function ImageDetail() { if (error) return (
- Error loading image: {error.message} + {t("imageDetail.errorLoading")}: {error.message}
); if (!image) - return
Image not found
; + return ( +
+ {t("imageDetail.imageNotFound")} +
+ ); const lineage = image.lineage; const currentIndex = lineage.findIndex((img) => img.id === image.id); @@ -80,10 +85,10 @@ export function ImageDetail() { variant="secondary" onClick={() => navigate(routes.profile.editImage(image.id))} > - Edit + {t("common.buttonLabel.edit")} @@ -115,7 +120,7 @@ export function ImageDetail() {

- Edit History + {t("imageDetail.editHistory")}

{lineage.map((ancestor, i) => { @@ -163,10 +168,10 @@ export function ImageDetail() { )} > {i === 0 - ? "Original" + ? t("imageDetail.original") : changeSet ? changeSet - : "Edit"} + : t("imageDetail.editFallback")}
@@ -182,6 +187,7 @@ export function ImageDetail() { } function CopyURLs({ url, name }: { url: string; name: string }) { + const { t } = useTranslation(); const formats = React.useMemo( () => [ { label: "URL", value: url }, @@ -195,7 +201,7 @@ function CopyURLs({ url, name }: { url: string; name: string }) { return (

- Copy References + {t("imageDetail.copyReferences")}

{formats.map((fmt) => ( @@ -251,7 +257,7 @@ function CopyRow({ label, value }: { label: string; value: string }) { onClick={handleCopy} disabled={justCopied} > - {justCopied ? t("common.toast.copied", "Copied!") : "Copy"} + {justCopied ? t("common.toast.copied") : t("imageDetail.copy")}
); diff --git a/web_client/src/editor/ImageEditor.tsx b/web_client/src/editor/ImageEditor.tsx index da73d65..db8a13b 100644 --- a/web_client/src/editor/ImageEditor.tsx +++ b/web_client/src/editor/ImageEditor.tsx @@ -7,6 +7,7 @@ import { EditorCanvas, OverlayState } from "./EditorCanvas"; import { WatermarkTool, WatermarkSettings } from "./WatermarkTool"; import { useApplyWatermark } from "./data"; import { toast } from "react-toastify"; +import { useTranslation } from "react-i18next"; import classNames from "classnames"; import { HEADING_2, @@ -46,6 +47,7 @@ export function ImageEditor() { const navigate = useNavigate(); const [fetchImage, { data, loading, error }] = useLazyQuery(ImageForEditorDoc); + const { t } = useTranslation(); const { execute: applyWatermark, loading: applying } = useApplyWatermark(); const [settings, setSettings] = React.useState({ @@ -127,38 +129,42 @@ export function ImageEditor() { }); const newImage = result.data?.applyWatermark.image; if (newImage) { - toast("Watermark applied successfully"); + toast(t("imageEditor.watermarkApplied")); navigate(routes.profile.image(newImage.id), { replace: true }); } } catch (_err) { - toast.error("Failed to apply watermark"); + toast.error(t("imageEditor.watermarkFailed")); } - }, [imageId, settings, applyWatermark, navigate]); + }, [t, imageId, settings, applyWatermark, navigate]); if (loading) return ; if (error) return (
- Error loading image: {error.message} + {t("imageEditor.errorLoading")}: {error.message}
); if (!image) - return
Image not found
; + return ( +
+ {t("imageEditor.imageNotFound")} +
+ ); return (

- Edit: {image.name} + {t("imageEditor.title", { name: image.name })}

{image.parent && (

- Derived from: {image.parent.name} + {t("imageEditor.derivedFrom", { name: image.parent.name })}

)} @@ -182,7 +188,7 @@ export function ImageEditor() {

- Watermark + {t("imageEditor.watermark")}

= { - [Anchor.TopLeft]: "Top Left", - [Anchor.TopRight]: "Top Right", - [Anchor.BottomLeft]: "Bottom Left", - [Anchor.BottomRight]: "Bottom Right", - [Anchor.Center]: "Center", +const ANCHOR_KEYS: Record = { + [Anchor.TopLeft]: "watermarkTool.anchorOption.topLeft", + [Anchor.TopRight]: "watermarkTool.anchorOption.topRight", + [Anchor.BottomLeft]: "watermarkTool.anchorOption.bottomLeft", + [Anchor.BottomRight]: "watermarkTool.anchorOption.bottomRight", + [Anchor.Center]: "watermarkTool.anchorOption.center", }; export function WatermarkTool({ @@ -42,6 +43,7 @@ export function WatermarkTool({ onApply, applying, }: WatermarkToolProps) { + const { t } = useTranslation(); const [showPicker, setShowPicker] = React.useState(false); const { data: authData } = useAuth(); const userId = authData?.viewer.organizationUser?.id; @@ -94,7 +96,7 @@ export function WatermarkTool({
{settings.overlayImageUrl ? (
@@ -107,7 +109,7 @@ export function WatermarkTool({ variant="secondary" onClick={() => setShowPicker(!showPicker)} > - Change + {t("watermarkTool.change")}
) : ( @@ -115,7 +117,7 @@ export function WatermarkTool({ variant="secondary" onClick={() => setShowPicker(!showPicker)} > - Select overlay image + {t("watermarkTool.selectOverlay")} )}
@@ -127,7 +129,9 @@ export function WatermarkTool({ {settings.overlayImageId && ( <> onSettingsChange({ @@ -166,9 +172,9 @@ export function WatermarkTool({ }) } > - {Object.entries(ANCHOR_LABELS).map(([value, label]) => ( + {Object.entries(ANCHOR_KEYS).map(([value, key]) => ( ))} @@ -178,7 +184,9 @@ export function WatermarkTool({ disabled={applying || !settings.overlayImageId} className="w-full" > - {applying ? "Applying..." : "Apply Watermark"} + {applying + ? t("watermarkTool.applying") + : t("watermarkTool.applyWatermark")} )} @@ -193,6 +201,7 @@ function OverlayPicker({ userId: string; itemRenderer: (image: RenderingImageItem) => React.ReactNode; }) { + const { t } = useTranslation(); const [search, setSearch] = React.useState(""); const [debouncedSearch, setDebouncedSearch] = React.useState(""); @@ -205,13 +214,13 @@ function OverlayPicker({
setSearch(e.target.value)} className="mb-2 w-full" />

- Select an image to use as watermark (base image is dimmed) + {t("watermarkTool.selectHint")}

{{name}}" }, + "imageDetail": { + "errorLoading": "Error loading image", + "imageNotFound": "Image not found", + "editHistory": "Edit History", + "original": "Original", + "editFallback": "Edit", + "copyReferences": "Copy References", + "copy": "Copy" + }, + "imageEditor": { + "title": "Edit: {{name}}", + "errorLoading": "Error loading image", + "imageNotFound": "Image not found", + "derivedFrom": "Derived from: {{name}}", + "watermarkApplied": "Watermark applied successfully", + "watermarkFailed": "Failed to apply watermark", + "watermark": "Watermark" + }, + "watermarkTool": { + "overlayImage": "Overlay Image", + "change": "Change", + "selectOverlay": "Select overlay image", + "opacity": "Opacity: {{value}}%", + "scale": "Scale: {{value}}%", + "anchor": "Anchor", + "applying": "Applying...", + "applyWatermark": "Apply Watermark", + "searchPlaceholder": "Search by name...", + "selectHint": "Select an image to use as watermark (base image is dimmed)", + "anchorOption": { + "topLeft": "Top Left", + "topRight": "Top Right", + "bottomLeft": "Bottom Left", + "bottomRight": "Bottom Right", + "center": "Center" + } + }, "profile": { "nav": { "general": "General", diff --git a/web_client/src/profile/layout.tsx b/web_client/src/profile/layout.tsx index 74db5f7..06ec9e1 100644 --- a/web_client/src/profile/layout.tsx +++ b/web_client/src/profile/layout.tsx @@ -1,11 +1,15 @@ import React from "react"; -import { Outlet } from "react-router"; +import { Navigate, Outlet } from "react-router"; import { PiImages as ImagesIcon } from "react-icons/pi"; import { DashboardLayout } from "~src/common/layout/dashboardLayout"; import { useTranslation } from "react-i18next"; +import { useAuth } from "~src/lib/auth"; +import { FullScreenLoader } from "~src/ui/fullscreenLoader"; export function ProfileLayout() { const { t } = useTranslation(); + const { data: authData, isLoading } = useAuth(); + const sideBarMenuGroups = React.useMemo( () => [ { @@ -24,6 +28,9 @@ export function ProfileLayout() { [t], ); + if (isLoading) return ; + if (!authData?.viewer.organizationUser) return ; + return ( Date: Mon, 1 Jun 2026 02:30:54 -0400 Subject: [PATCH 04/15] sync up translations --- web_client/src/localization/ko.json | 45 ++++++++++++++++++++++-- web_client/src/localization/ru.json | 45 ++++++++++++++++++++++-- web_client/src/localization/th.json | 45 ++++++++++++++++++++++-- web_client/src/localization/zh_hans.json | 45 ++++++++++++++++++++++-- web_client/src/localization/zh_hant.json | 45 ++++++++++++++++++++++-- 5 files changed, 215 insertions(+), 10 deletions(-) diff --git a/web_client/src/localization/ko.json b/web_client/src/localization/ko.json index 182f644..b98f2ec 100644 --- a/web_client/src/localization/ko.json +++ b/web_client/src/localization/ko.json @@ -5,7 +5,8 @@ "save": "저장", "cancel": "취소", "delete": "삭제", - "deleteWithConfirm": "삭제…" + "deleteWithConfirm": "삭제…", + "back": "뒤로" }, "toast": { "copied": "복사됨", @@ -131,7 +132,8 @@ "showingResults": "전체 {{total}}개 중 {{from}}–{{to}}개 표시", "previous": "이전", "next": "다음", - "pageInfo": "{{total}} 페이지 중 {{current}} 페이지" + "pageInfo": "{{total}} 페이지 중 {{current}} 페이지", + "pageSize": "페이지 당" }, "usersTable": { "name": "이름", @@ -141,10 +143,49 @@ "viewImagesButton": "이미지 보기" }, "imageItem": { + "details": "상세 정보", + "edit": "편집", "copyURL": "URL 복사", "download": "다운로드", "confirmDelete": "이 이미지를 삭제하시겠습니까?
{{name}}" }, + "imageDetail": { + "errorLoading": "이미지 로드 오류", + "imageNotFound": "이미지를 찾을 수 없습니다", + "editHistory": "편집 이력", + "original": "원본", + "editFallback": "편집", + "copyReferences": "참조 복사", + "copy": "복사" + }, + "imageEditor": { + "title": "편집: {{name}}", + "errorLoading": "이미지 로드 오류", + "imageNotFound": "이미지를 찾을 수 없습니다", + "derivedFrom": "원본: {{name}}", + "watermarkApplied": "워터마크가 적용되었습니다", + "watermarkFailed": "워터마크 적용에 실패했습니다", + "watermark": "워터마크" + }, + "watermarkTool": { + "overlayImage": "오버레이 이미지", + "change": "변경", + "selectOverlay": "오버레이 이미지 선택", + "opacity": "불투명도: {{value}}%", + "scale": "크기: {{value}}%", + "anchor": "기준점", + "applying": "적용 중...", + "applyWatermark": "워터마크 적용", + "searchPlaceholder": "이름으로 검색...", + "selectHint": "워터마크로 사용할 이미지를 선택하세요 (원본 이미지는 흐리게 표시됩니다)", + "anchorOption": { + "topLeft": "왼쪽 상단", + "topRight": "오른쪽 상단", + "bottomLeft": "왼쪽 하단", + "bottomRight": "오른쪽 하단", + "center": "중앙" + } + }, "profile": { "nav": { "general": "일반", diff --git a/web_client/src/localization/ru.json b/web_client/src/localization/ru.json index 5a92a6f..299fa69 100644 --- a/web_client/src/localization/ru.json +++ b/web_client/src/localization/ru.json @@ -5,7 +5,8 @@ "save": "Сохранить", "cancel": "Отмена", "delete": "Удалить", - "deleteWithConfirm": "Удалить…" + "deleteWithConfirm": "Удалить…", + "back": "Назад" }, "toast": { "copied": "Скопировано", @@ -131,7 +132,8 @@ "showingResults": "Показано с {{from}} по {{to}} из {{total}} результатов", "previous": "Назад", "next": "Вперёд", - "pageInfo": "Страница {{current}} из {{total}}" + "pageInfo": "Страница {{current}} из {{total}}", + "pageSize": "На странице" }, "usersTable": { "name": "Имя", @@ -141,10 +143,49 @@ "viewImagesButton": "Просмотр изображений" }, "imageItem": { + "details": "Подробности", + "edit": "Изменить", "copyURL": "Копировать URL", "download": "Скачать", "confirmDelete": "Вы уверены, что хотите удалить это изображение?
{{name}}" }, + "imageDetail": { + "errorLoading": "Ошибка загрузки изображения", + "imageNotFound": "Изображение не найдено", + "editHistory": "История изменений", + "original": "Оригинал", + "editFallback": "Изменение", + "copyReferences": "Копировать ссылки", + "copy": "Копировать" + }, + "imageEditor": { + "title": "Редактирование: {{name}}", + "errorLoading": "Ошибка загрузки изображения", + "imageNotFound": "Изображение не найдено", + "derivedFrom": "Источник: {{name}}", + "watermarkApplied": "Водяной знак успешно применён", + "watermarkFailed": "Не удалось применить водяной знак", + "watermark": "Водяной знак" + }, + "watermarkTool": { + "overlayImage": "Накладываемое изображение", + "change": "Изменить", + "selectOverlay": "Выбрать накладываемое изображение", + "opacity": "Непрозрачность: {{value}}%", + "scale": "Масштаб: {{value}}%", + "anchor": "Привязка", + "applying": "Применение...", + "applyWatermark": "Применить водяной знак", + "searchPlaceholder": "Поиск по имени...", + "selectHint": "Выберите изображение для водяного знака (исходное изображение затемнено)", + "anchorOption": { + "topLeft": "Верх слева", + "topRight": "Верх справа", + "bottomLeft": "Низ слева", + "bottomRight": "Низ справа", + "center": "По центру" + } + }, "profile": { "nav": { "general": "Общие", diff --git a/web_client/src/localization/th.json b/web_client/src/localization/th.json index 3354653..970fdf7 100644 --- a/web_client/src/localization/th.json +++ b/web_client/src/localization/th.json @@ -5,7 +5,8 @@ "save": "บันทึก", "cancel": "ยกเลิก", "delete": "ลบ", - "deleteWithConfirm": "ลบ…" + "deleteWithConfirm": "ลบ…", + "back": "กลับ" }, "toast": { "copied": "คัดลอกแล้ว", @@ -131,7 +132,8 @@ "showingResults": "แสดง {{from}} ถึง {{to}} จากทั้งหมด {{total}} รายการ", "previous": "ก่อนหน้า", "next": "ถัดไป", - "pageInfo": "หน้า {{current}} จาก {{total}}" + "pageInfo": "หน้า {{current}} จาก {{total}}", + "pageSize": "ต่อหน้า" }, "usersTable": { "name": "ชื่อ", @@ -141,10 +143,49 @@ "viewImagesButton": "ดูภาพ" }, "imageItem": { + "details": "รายละเอียด", + "edit": "แก้ไข", "copyURL": "คัดลอก URL", "download": "ดาวน์โหลด", "confirmDelete": "คุณแน่ใจหรือไม่ว่าต้องการลบภาพนี้?
{{name}}" }, + "imageDetail": { + "errorLoading": "เกิดข้อผิดพลาดในการโหลดภาพ", + "imageNotFound": "ไม่พบภาพ", + "editHistory": "ประวัติการแก้ไข", + "original": "ต้นฉบับ", + "editFallback": "แก้ไข", + "copyReferences": "คัดลอกลิงก์อ้างอิง", + "copy": "คัดลอก" + }, + "imageEditor": { + "title": "แก้ไข: {{name}}", + "errorLoading": "เกิดข้อผิดพลาดในการโหลดภาพ", + "imageNotFound": "ไม่พบภาพ", + "derivedFrom": "ต้นฉบับ: {{name}}", + "watermarkApplied": "ใส่ลายน้ำสำเร็จ", + "watermarkFailed": "ใส่ลายน้ำไม่สำเร็จ", + "watermark": "ลายน้ำ" + }, + "watermarkTool": { + "overlayImage": "ภาพซ้อนทับ", + "change": "เปลี่ยน", + "selectOverlay": "เลือกภาพซ้อนทับ", + "opacity": "ความทึบ: {{value}}%", + "scale": "ขนาด: {{value}}%", + "anchor": "จุดยึด", + "applying": "กำลังใช้งาน...", + "applyWatermark": "ใส่ลายน้ำ", + "searchPlaceholder": "ค้นหาด้วยชื่อ...", + "selectHint": "เลือกภาพเพื่อใช้เป็นลายน้ำ (ภาพต้นฉบับจะแสดงจางลง)", + "anchorOption": { + "topLeft": "บนซ้าย", + "topRight": "บนขวา", + "bottomLeft": "ล่างซ้าย", + "bottomRight": "ล่างขวา", + "center": "กลาง" + } + }, "profile": { "nav": { "general": "ทั่วไป", diff --git a/web_client/src/localization/zh_hans.json b/web_client/src/localization/zh_hans.json index a4e2d98..aaea8e8 100644 --- a/web_client/src/localization/zh_hans.json +++ b/web_client/src/localization/zh_hans.json @@ -5,7 +5,8 @@ "save": "保存", "cancel": "取消", "delete": "删除", - "deleteWithConfirm": "删除…" + "deleteWithConfirm": "删除…", + "back": "返回" }, "toast": { "copied": "已复制", @@ -131,7 +132,8 @@ "showingResults": "显示第 {{from}} 到 {{to}} 条,共 {{total}} 条", "previous": "上一页", "next": "下一页", - "pageInfo": "第 {{current}} 页,共 {{total}} 页" + "pageInfo": "第 {{current}} 页,共 {{total}} 页", + "pageSize": "每页" }, "usersTable": { "name": "姓名", @@ -141,10 +143,49 @@ "viewImagesButton": "查看图片" }, "imageItem": { + "details": "详情", + "edit": "编辑", "copyURL": "复制链接", "download": "下载", "confirmDelete": "确定要删除这张图片吗?
{{name}}" }, + "imageDetail": { + "errorLoading": "加载图片出错", + "imageNotFound": "未找到图片", + "editHistory": "编辑历史", + "original": "原图", + "editFallback": "编辑", + "copyReferences": "复制引用", + "copy": "复制" + }, + "imageEditor": { + "title": "编辑: {{name}}", + "errorLoading": "加载图片出错", + "imageNotFound": "未找到图片", + "derivedFrom": "来源: {{name}}", + "watermarkApplied": "水印已成功应用", + "watermarkFailed": "水印应用失败", + "watermark": "水印" + }, + "watermarkTool": { + "overlayImage": "叠加图片", + "change": "更换", + "selectOverlay": "选择叠加图片", + "opacity": "不透明度: {{value}}%", + "scale": "缩放: {{value}}%", + "anchor": "锚点", + "applying": "应用中...", + "applyWatermark": "应用水印", + "searchPlaceholder": "按名称搜索...", + "selectHint": "选择一张图片作为水印(原图显示为半透明)", + "anchorOption": { + "topLeft": "左上", + "topRight": "右上", + "bottomLeft": "左下", + "bottomRight": "右下", + "center": "居中" + } + }, "profile": { "nav": { "general": "常规", diff --git a/web_client/src/localization/zh_hant.json b/web_client/src/localization/zh_hant.json index 1264f52..3e702b2 100644 --- a/web_client/src/localization/zh_hant.json +++ b/web_client/src/localization/zh_hant.json @@ -5,7 +5,8 @@ "save": "儲存", "cancel": "取消", "delete": "刪除", - "deleteWithConfirm": "刪除…" + "deleteWithConfirm": "刪除…", + "back": "返回" }, "toast": { "copied": "已複製", @@ -131,7 +132,8 @@ "showingResults": "顯示第 {{from}} 到 {{to}} 筆,共 {{total}} 筆", "previous": "上一頁", "next": "下一頁", - "pageInfo": "第 {{current}} 頁,共 {{total}} 頁" + "pageInfo": "第 {{current}} 頁,共 {{total}} 頁", + "pageSize": "每頁" }, "usersTable": { "name": "姓名", @@ -141,10 +143,49 @@ "viewImagesButton": "檢視圖片" }, "imageItem": { + "details": "詳細資訊", + "edit": "編輯", "copyURL": "複製連結", "download": "下載", "confirmDelete": "確定要刪除這張圖片嗎?
{{name}}" }, + "imageDetail": { + "errorLoading": "載入圖片時出錯", + "imageNotFound": "找不到圖片", + "editHistory": "編輯歷史", + "original": "原圖", + "editFallback": "編輯", + "copyReferences": "複製參照", + "copy": "複製" + }, + "imageEditor": { + "title": "編輯: {{name}}", + "errorLoading": "載入圖片時出錯", + "imageNotFound": "找不到圖片", + "derivedFrom": "來源: {{name}}", + "watermarkApplied": "浮水印已成功套用", + "watermarkFailed": "浮水印套用失敗", + "watermark": "浮水印" + }, + "watermarkTool": { + "overlayImage": "疊加圖片", + "change": "更換", + "selectOverlay": "選擇疊加圖片", + "opacity": "不透明度: {{value}}%", + "scale": "縮放: {{value}}%", + "anchor": "錨點", + "applying": "套用中...", + "applyWatermark": "套用浮水印", + "searchPlaceholder": "依名稱搜尋...", + "selectHint": "選擇一張圖片作為浮水印(原圖顯示為半透明)", + "anchorOption": { + "topLeft": "左上", + "topRight": "右上", + "bottomLeft": "左下", + "bottomRight": "右下", + "center": "置中" + } + }, "profile": { "nav": { "general": "一般", From 05e1cc43cd35215dda6add0b0e0f2960d8c76331 Mon Sep 17 00:00:00 2001 From: shen Date: Mon, 1 Jun 2026 21:57:40 -0400 Subject: [PATCH 05/15] track relationships as dag --- .../imgdd/public/model/image_parent_table.go | 21 ++ .../imgdd/public/table/image_parent_table.go | 90 ++++++ .../imgdd/public/table/table_use_schema.go | 1 + .../000005_create_image_parent_table.down.sql | 1 + .../000005_create_image_parent_table.up.sql | 39 +++ graph/graph_test.go | 4 + graph/images.resolvers.go | 17 ++ graph/images.resolvers_test.go | 286 ++++++++++++++++++ graph/resolver.go | 1 + httpserver/make_server.go | 2 + httpserver/utils.go | 2 + image/relationship_repo.go | 262 ++++++++++++++++ web_client/src/common/ImageGallery/render.tsx | 2 - web_client/src/editor/ImageEditor.tsx | 2 +- 14 files changed, 727 insertions(+), 3 deletions(-) create mode 100644 db/.gen/imgdd/public/model/image_parent_table.go create mode 100644 db/.gen/imgdd/public/table/image_parent_table.go create mode 100644 db/migrations/000005_create_image_parent_table.down.sql create mode 100644 db/migrations/000005_create_image_parent_table.up.sql create mode 100644 image/relationship_repo.go diff --git a/db/.gen/imgdd/public/model/image_parent_table.go b/db/.gen/imgdd/public/model/image_parent_table.go new file mode 100644 index 0000000..67c08a4 --- /dev/null +++ b/db/.gen/imgdd/public/model/image_parent_table.go @@ -0,0 +1,21 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type ImageParentTable struct { + ID uuid.UUID `sql:"primary_key"` + ImageID uuid.UUID + ParentImageID uuid.UUID + RelationshipType string + CreatedAt time.Time +} diff --git a/db/.gen/imgdd/public/table/image_parent_table.go b/db/.gen/imgdd/public/table/image_parent_table.go new file mode 100644 index 0000000..1b77284 --- /dev/null +++ b/db/.gen/imgdd/public/table/image_parent_table.go @@ -0,0 +1,90 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var ImageParentTable = newImageParentTableTable("public", "image_parent_table", "") + +type imageParentTableTable struct { + postgres.Table + + // Columns + ID postgres.ColumnString + ImageID postgres.ColumnString + ParentImageID postgres.ColumnString + RelationshipType postgres.ColumnString + CreatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type ImageParentTableTable struct { + imageParentTableTable + + EXCLUDED imageParentTableTable +} + +// AS creates new ImageParentTableTable with assigned alias +func (a ImageParentTableTable) AS(alias string) *ImageParentTableTable { + return newImageParentTableTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new ImageParentTableTable with assigned schema name +func (a ImageParentTableTable) FromSchema(schemaName string) *ImageParentTableTable { + return newImageParentTableTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new ImageParentTableTable with assigned table prefix +func (a ImageParentTableTable) WithPrefix(prefix string) *ImageParentTableTable { + return newImageParentTableTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new ImageParentTableTable with assigned table suffix +func (a ImageParentTableTable) WithSuffix(suffix string) *ImageParentTableTable { + return newImageParentTableTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newImageParentTableTable(schemaName, tableName, alias string) *ImageParentTableTable { + return &ImageParentTableTable{ + imageParentTableTable: newImageParentTableTableImpl(schemaName, tableName, alias), + EXCLUDED: newImageParentTableTableImpl("", "excluded", ""), + } +} + +func newImageParentTableTableImpl(schemaName, tableName, alias string) imageParentTableTable { + var ( + IDColumn = postgres.StringColumn("id") + ImageIDColumn = postgres.StringColumn("image_id") + ParentImageIDColumn = postgres.StringColumn("parent_image_id") + RelationshipTypeColumn = postgres.StringColumn("relationship_type") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + allColumns = postgres.ColumnList{IDColumn, ImageIDColumn, ParentImageIDColumn, RelationshipTypeColumn, CreatedAtColumn} + mutableColumns = postgres.ColumnList{ImageIDColumn, ParentImageIDColumn, RelationshipTypeColumn, CreatedAtColumn} + defaultColumns = postgres.ColumnList{IDColumn, CreatedAtColumn} + ) + + return imageParentTableTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ID: IDColumn, + ImageID: ImageIDColumn, + ParentImageID: ParentImageIDColumn, + RelationshipType: RelationshipTypeColumn, + CreatedAt: CreatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/db/.gen/imgdd/public/table/table_use_schema.go b/db/.gen/imgdd/public/table/table_use_schema.go index 8d9c7a6..b644f9e 100644 --- a/db/.gen/imgdd/public/table/table_use_schema.go +++ b/db/.gen/imgdd/public/table/table_use_schema.go @@ -10,6 +10,7 @@ package table // UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke // this method only once at the beginning of the program. func UseSchema(schema string) { + ImageParentTable = ImageParentTable.FromSchema(schema) ImageTable = ImageTable.FromSchema(schema) OrganizationTable = OrganizationTable.FromSchema(schema) OrganizationUserRoleTable = OrganizationUserRoleTable.FromSchema(schema) diff --git a/db/migrations/000005_create_image_parent_table.down.sql b/db/migrations/000005_create_image_parent_table.down.sql new file mode 100644 index 0000000..eaf6bb8 --- /dev/null +++ b/db/migrations/000005_create_image_parent_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS image_parent_table; diff --git a/db/migrations/000005_create_image_parent_table.up.sql b/db/migrations/000005_create_image_parent_table.up.sql new file mode 100644 index 0000000..61930b7 --- /dev/null +++ b/db/migrations/000005_create_image_parent_table.up.sql @@ -0,0 +1,39 @@ +BEGIN; + +CREATE TABLE image_parent_table ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + image_id UUID NOT NULL REFERENCES image_table(id) ON DELETE RESTRICT, + parent_image_id UUID NOT NULL REFERENCES image_table(id) ON DELETE RESTRICT, + relationship_type CHARACTER VARYING(50) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + + CONSTRAINT image_parent_unique_relationship UNIQUE(image_id, parent_image_id, relationship_type) +); + +CREATE INDEX image_parent_table_image_id_idx ON image_parent_table(image_id); +CREATE INDEX image_parent_table_parent_image_id_idx ON image_parent_table(parent_image_id); + +-- Backfill from existing parent_id column +INSERT INTO image_parent_table (image_id, parent_image_id, relationship_type) +SELECT id, parent_id, 'base' +FROM image_table +WHERE parent_id IS NOT NULL AND deleted_at IS NULL; + +-- Backfill overlay relationships from changes JSON +INSERT INTO image_parent_table (image_id, parent_image_id, relationship_type) +SELECT + it.id, + (it.changes->'params'->>'overlay_image_id')::UUID, + 'overlay' +FROM image_table it +WHERE it.changes != '{}' + AND it.changes->>'type' = 'watermark' + AND it.changes->'params'->>'overlay_image_id' IS NOT NULL + AND it.deleted_at IS NULL + AND EXISTS ( + SELECT 1 FROM image_table ot + WHERE ot.id = (it.changes->'params'->>'overlay_image_id')::UUID + AND ot.deleted_at IS NULL + ); + +COMMIT; diff --git a/graph/graph_test.go b/graph/graph_test.go index e79244d..67ee85b 100644 --- a/graph/graph_test.go +++ b/graph/graph_test.go @@ -43,6 +43,7 @@ type TestContext struct { identityRepo identity.IdentityRepo storageDefRepo storage.StorageDefRepo imageRepo image.ImageRepo + imageRelRepo image.ImageRelationshipRepo identityManager *httpserver.IdentityManager tObj *testing.T server *httptest.Server @@ -90,12 +91,14 @@ func newTestContext(tObj *testing.T) *TestContext { storageDefRepo := storage.NewDBStorageConfig(conn).MakeStorageDefRepo() storedImageRepo := storage.NewDBStoredImageRepo(conn) imageRepo := image.NewDBImageRepo(conn) + imageRelRepo := image.NewDBImageRelationshipRepo(conn) dummyEmailBackend := email.NewDummyBackend() resolver := httpserver.NewGqlResolver( identityManager, storageDefRepo, storedImageRepo, imageRepo, + imageRelRepo, "", domainmodels.ImageURLFormat_CANONICAL, func(c context.Context) email.EmailBackend { @@ -123,6 +126,7 @@ func newTestContext(tObj *testing.T) *TestContext { identityRepo: identityRepo, storageDefRepo: storageDefRepo, imageRepo: imageRepo, + imageRelRepo: imageRelRepo, identityManager: identityManager, tObj: tObj, server: server, diff --git a/graph/images.resolvers.go b/graph/images.resolvers.go index 9da96ee..67bb552 100644 --- a/graph/images.resolvers.go +++ b/graph/images.resolvers.go @@ -149,6 +149,14 @@ func (r *mutationResolver) DeleteImage(ctx context.Context, input model.DeleteIm if !currentUser.CanManage(createdBy) { return nil, fmt.Errorf("unauthorized") } + // Block deletion if image has relationships (as parent or child) + hasRels, err := r.ImageRelRepo.HasRelationships(input.ID) + if err != nil { + return nil, fmt.Errorf("failed to check image relationships: %w", err) + } + if hasRels { + return nil, fmt.Errorf("cannot delete image with edit relationships") + } if err := r.ImageRepo.DeleteImageById(input.ID); err != nil { return nil, err } else { @@ -299,6 +307,15 @@ func (r *mutationResolver) ApplyWatermark(ctx context.Context, input model.Apply return nil, fmt.Errorf("failed to save result image: %w", err) } + // Record DAG relationships + newImageId := storedImage.Image.Id + if _, err := r.ImageRelRepo.CreateRelationship(newImageId, input.BaseImageID, image.RelationshipTypeBase); err != nil { + return nil, fmt.Errorf("failed to create base relationship: %w", err) + } + if _, err := r.ImageRelRepo.CreateRelationship(newImageId, input.OverlayImageID, image.RelationshipTypeOverlay); err != nil { + return nil, fmt.Errorf("failed to create overlay relationship: %w", err) + } + return &model.ApplyWatermarkResult{ Image: model.FromImage(storedImage.Image), }, nil diff --git a/graph/images.resolvers_test.go b/graph/images.resolvers_test.go index b88e827..e7271d4 100644 --- a/graph/images.resolvers_test.go +++ b/graph/images.resolvers_test.go @@ -10,6 +10,7 @@ import ( "github.com/ericls/imgdd/domainmodels" "github.com/ericls/imgdd/graph/model" + imgddimage "github.com/ericls/imgdd/image" "github.com/ericls/imgdd/storage" "github.com/ericls/imgdd/utils" @@ -577,6 +578,28 @@ func tApplyWatermark(t *testing.T, tc *TestContext) { require.NoError(t, err) require.Equal(t, baseImage.Id, newImage.ParentId) require.Equal(t, baseImage.Id, newImage.RootId) + + // Verify DAG relationships were created + parents, err := tc.imageRelRepo.GetParentsByImageId(newImage.Id) + require.NoError(t, err) + require.Len(t, parents, 2) + relTypes := map[string]string{} + for _, p := range parents { + relTypes[p.RelationshipType] = p.ParentImageId + } + require.Equal(t, baseImage.Id, relTypes["base"]) + require.Equal(t, overlayImage.Id, relTypes["overlay"]) + + // Verify children queries work + baseChildren, err := tc.imageRelRepo.GetChildrenByImageId(baseImage.Id) + require.NoError(t, err) + require.Len(t, baseChildren, 1) + require.Equal(t, newImage.Id, baseChildren[0].ImageId) + + overlayChildren, err := tc.imageRelRepo.GetChildrenByImageId(overlayImage.Id) + require.NoError(t, err) + require.Len(t, overlayChildren, 1) + require.Equal(t, newImage.Id, overlayChildren[0].ImageId) } func tApplyWatermarkUnauthenticated(t *testing.T, tc *TestContext) { @@ -874,6 +897,265 @@ func tImageNoParentLineage(t *testing.T, tc *TestContext) { require.Equal(t, img.Id, resp.Viewer.Image.Lineage[0].ID) } +func tDeleteImageBlockedByRelationship(t *testing.T, tc *TestContext) { + orgUser := tc.forceAuthenticate() + sd, tempDir := createFSStorageDefinition(t, tc) + defer os.RemoveAll(tempDir) + + baseBytes := makeTestPNGBytes(100, 100, color.RGBA{255, 0, 0, 255}) + overlayBytes := makeTestPNGBytes(20, 20, color.RGBA{0, 0, 255, 255}) + baseImage := createRealImage(t, tc, orgUser.Id, sd.Id, baseBytes) + overlayImage := createRealImage(t, tc, orgUser.Id, sd.Id, overlayBytes) + + // Apply watermark to create relationships + var applyResp struct { + ApplyWatermark *struct { + Image *struct{ ID string } + } + } + err := tc.client.Post(` + mutation applyWatermark($input: ApplyWatermarkInput!) { + applyWatermark(input: $input) { + image { id } + } + }`, &applyResp, client.Var("input", map[string]interface{}{ + "baseImageId": baseImage.Id, + "overlayImageId": overlayImage.Id, + "position": map[string]float64{"x": 0.5, "y": 0.5}, + "anchor": "CENTER", + "opacity": 0.5, + "scale": 0.2, + })) + require.NoError(t, err) + childId := applyResp.ApplyWatermark.Image.ID + + // Try to delete the base image — should fail + var deleteResp struct { + DeleteImage *struct{ ID *string } + } + err = tc.client.Post(` + mutation deleteImage($input: DeleteImageInput!) { + deleteImage(input: $input) { id } + }`, &deleteResp, client.Var("input", map[string]interface{}{ + "id": baseImage.Id, + })) + require.Error(t, err) + require.Contains(t, err.Error(), "edit relationships") + + // Try to delete the overlay image — should fail + err = tc.client.Post(` + mutation deleteImage($input: DeleteImageInput!) { + deleteImage(input: $input) { id } + }`, &deleteResp, client.Var("input", map[string]interface{}{ + "id": overlayImage.Id, + })) + require.Error(t, err) + require.Contains(t, err.Error(), "edit relationships") + + // Try to delete the child image — should also fail (it has relationships as child) + err = tc.client.Post(` + mutation deleteImage($input: DeleteImageInput!) { + deleteImage(input: $input) { id } + }`, &deleteResp, client.Var("input", map[string]interface{}{ + "id": childId, + })) + require.Error(t, err) + require.Contains(t, err.Error(), "edit relationships") +} + +func tDAGNoCycles(t *testing.T, tc *TestContext) { + orgUser := tc.forceAuthenticate() + sd, tempDir := createFSStorageDefinition(t, tc) + defer os.RemoveAll(tempDir) + + imgBytes := makeTestPNGBytes(100, 100, color.White) + imgA := createRealImage(t, tc, orgUser.Id, sd.Id, imgBytes) + imgB := createRealImage(t, tc, orgUser.Id, sd.Id, imgBytes) + imgC := createRealImage(t, tc, orgUser.Id, sd.Id, imgBytes) + + // A -> B -> C (B is child of A, C is child of B) + _, err := tc.imageRelRepo.CreateRelationship(imgB.Id, imgA.Id, imgddimage.RelationshipTypeBase) + require.NoError(t, err) + _, err = tc.imageRelRepo.CreateRelationship(imgC.Id, imgB.Id, imgddimage.RelationshipTypeBase) + require.NoError(t, err) + + // Self-reference: A -> A should fail + _, err = tc.imageRelRepo.CreateRelationship(imgA.Id, imgA.Id, imgddimage.RelationshipTypeBase) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot be its own parent") + + // Direct cycle: A -> C (C is already a descendant of A) should fail + _, err = tc.imageRelRepo.CreateRelationship(imgA.Id, imgC.Id, imgddimage.RelationshipTypeBase) + require.Error(t, err) + require.Contains(t, err.Error(), "would form a cycle") + + // Adding A as a parent of C is NOT a cycle — it's a diamond (A -> B -> C, A -> C) + _, err = tc.imageRelRepo.CreateRelationship(imgC.Id, imgA.Id, "overlay") + require.NoError(t, err) + + // A valid new relationship that doesn't form a cycle should succeed + imgD := createRealImage(t, tc, orgUser.Id, sd.Id, imgBytes) + _, err = tc.imageRelRepo.CreateRelationship(imgD.Id, imgC.Id, imgddimage.RelationshipTypeBase) + require.NoError(t, err) + + // Making A a child of D should fail (A -> B -> C -> D already exists, D -> A would be a cycle) + _, err = tc.imageRelRepo.CreateRelationship(imgA.Id, imgD.Id, "overlay") + require.Error(t, err) + require.Contains(t, err.Error(), "would form a cycle") +} + +func tDAGQueriesDescendantsAncestorsRelated(t *testing.T, tc *TestContext) { + orgUser := tc.forceAuthenticate() + sd, tempDir := createFSStorageDefinition(t, tc) + defer os.RemoveAll(tempDir) + + imgBytes := makeTestPNGBytes(100, 100, color.White) + // Build: root -> mid -> leaf, root -> mid2 (diamond doesn't apply here, just a tree with branch) + root := createRealImage(t, tc, orgUser.Id, sd.Id, imgBytes) + mid := createRealImage(t, tc, orgUser.Id, sd.Id, imgBytes) + leaf := createRealImage(t, tc, orgUser.Id, sd.Id, imgBytes) + unrelated := createRealImage(t, tc, orgUser.Id, sd.Id, imgBytes) + + _, err := tc.imageRelRepo.CreateRelationship(mid.Id, root.Id, imgddimage.RelationshipTypeBase) + require.NoError(t, err) + _, err = tc.imageRelRepo.CreateRelationship(leaf.Id, mid.Id, imgddimage.RelationshipTypeBase) + require.NoError(t, err) + + // GetDescendantIds from root should return mid + leaf + descendants, err := tc.imageRelRepo.GetDescendantIds(root.Id) + require.NoError(t, err) + require.Len(t, descendants, 2) + descSet := map[string]bool{} + for _, id := range descendants { + descSet[id] = true + } + require.True(t, descSet[mid.Id]) + require.True(t, descSet[leaf.Id]) + + // GetDescendantIds from mid should return leaf only + descendants, err = tc.imageRelRepo.GetDescendantIds(mid.Id) + require.NoError(t, err) + require.Len(t, descendants, 1) + require.Equal(t, leaf.Id, descendants[0]) + + // GetDescendantIds from leaf should return empty + descendants, err = tc.imageRelRepo.GetDescendantIds(leaf.Id) + require.NoError(t, err) + require.Empty(t, descendants) + + // GetAncestorIds from leaf should return mid + root + ancestors, err := tc.imageRelRepo.GetAncestorIds(leaf.Id) + require.NoError(t, err) + require.Len(t, ancestors, 2) + ancSet := map[string]bool{} + for _, id := range ancestors { + ancSet[id] = true + } + require.True(t, ancSet[mid.Id]) + require.True(t, ancSet[root.Id]) + + // GetAncestorIds from root should return empty + ancestors, err = tc.imageRelRepo.GetAncestorIds(root.Id) + require.NoError(t, err) + require.Empty(t, ancestors) + + // AreRelated + related, err := tc.imageRelRepo.AreRelated(root.Id, leaf.Id) + require.NoError(t, err) + require.True(t, related) + + related, err = tc.imageRelRepo.AreRelated(leaf.Id, root.Id) + require.NoError(t, err) + require.True(t, related) + + related, err = tc.imageRelRepo.AreRelated(root.Id, unrelated.Id) + require.NoError(t, err) + require.False(t, related) + + related, err = tc.imageRelRepo.AreRelated(mid.Id, leaf.Id) + require.NoError(t, err) + require.True(t, related) + + // IsAncestor + isAnc, err := tc.imageRelRepo.IsAncestor(leaf.Id, root.Id) + require.NoError(t, err) + require.True(t, isAnc) + + isAnc, err = tc.imageRelRepo.IsAncestor(root.Id, leaf.Id) + require.NoError(t, err) + require.False(t, isAnc) +} + +func tDAGDiamondShape(t *testing.T, tc *TestContext) { + orgUser := tc.forceAuthenticate() + sd, tempDir := createFSStorageDefinition(t, tc) + defer os.RemoveAll(tempDir) + + imgBytes := makeTestPNGBytes(100, 100, color.White) + // A + // / \ + // B C + // \ / + // D + imgA := createRealImage(t, tc, orgUser.Id, sd.Id, imgBytes) + imgB := createRealImage(t, tc, orgUser.Id, sd.Id, imgBytes) + imgC := createRealImage(t, tc, orgUser.Id, sd.Id, imgBytes) + imgD := createRealImage(t, tc, orgUser.Id, sd.Id, imgBytes) + + _, err := tc.imageRelRepo.CreateRelationship(imgB.Id, imgA.Id, imgddimage.RelationshipTypeBase) + require.NoError(t, err) + _, err = tc.imageRelRepo.CreateRelationship(imgC.Id, imgA.Id, imgddimage.RelationshipTypeBase) + require.NoError(t, err) + _, err = tc.imageRelRepo.CreateRelationship(imgD.Id, imgB.Id, imgddimage.RelationshipTypeBase) + require.NoError(t, err) + _, err = tc.imageRelRepo.CreateRelationship(imgD.Id, imgC.Id, imgddimage.RelationshipTypeOverlay) + require.NoError(t, err) + + // D has two parents: B and C + parents, err := tc.imageRelRepo.GetParentsByImageId(imgD.Id) + require.NoError(t, err) + require.Len(t, parents, 2) + + // A has two children: B and C + children, err := tc.imageRelRepo.GetChildrenByImageId(imgA.Id) + require.NoError(t, err) + require.Len(t, children, 2) + + // All descendants of A: B, C, D + desc, err := tc.imageRelRepo.GetDescendantIds(imgA.Id) + require.NoError(t, err) + require.Len(t, desc, 3) + + // All ancestors of D: B, C, A + anc, err := tc.imageRelRepo.GetAncestorIds(imgD.Id) + require.NoError(t, err) + require.Len(t, anc, 3) + + // A and D are related + related, err := tc.imageRelRepo.AreRelated(imgA.Id, imgD.Id) + require.NoError(t, err) + require.True(t, related) + + // B and C are both related to D + related, err = tc.imageRelRepo.AreRelated(imgB.Id, imgD.Id) + require.NoError(t, err) + require.True(t, related) + + // B and C are siblings (both children of A) but not ancestor/descendant of each other + related, err = tc.imageRelRepo.AreRelated(imgB.Id, imgC.Id) + require.NoError(t, err) + require.False(t, related) + + // D -> A (adding A as parent of D) is valid — A is already an ancestor, this just adds a shortcut + _, err = tc.imageRelRepo.CreateRelationship(imgD.Id, imgA.Id, "overlay") + require.NoError(t, err) + + // A -> D (making D a parent of A) would create a cycle: A -> B -> D -> A + _, err = tc.imageRelRepo.CreateRelationship(imgA.Id, imgD.Id, "overlay") + require.Error(t, err) + require.Contains(t, err.Error(), "would form a cycle") +} + func TestImageResolvers(t *testing.T) { tc := newTestContext(t) tc.runTestCases( @@ -894,5 +1176,9 @@ func TestImageResolvers(t *testing.T) { tViewerImageInvalidId, tImageLineageAndRoot, tImageNoParentLineage, + tDeleteImageBlockedByRelationship, + tDAGNoCycles, + tDAGQueriesDescendantsAncestorsRelated, + tDAGDiamondShape, ) } diff --git a/graph/resolver.go b/graph/resolver.go index 2bf255e..825cad9 100644 --- a/graph/resolver.go +++ b/graph/resolver.go @@ -22,6 +22,7 @@ type Resolver struct { StorageDefRepo storage.StorageDefRepo StoredImageRepo storage.StoredImageRepo ImageRepo image.ImageRepo + ImageRelRepo image.ImageRelationshipRepo ContextUserManager identity.ContextUserManager LoginFn func(c context.Context, userId string, organizationUserId string) LogoutFn func(c context.Context) diff --git a/httpserver/make_server.go b/httpserver/make_server.go index 2eaa0dd..7652b75 100644 --- a/httpserver/make_server.go +++ b/httpserver/make_server.go @@ -75,6 +75,7 @@ func MakeServer( storageDefRepo := storageConf.MakeStorageDefRepo() storedImageRepo := storage.NewDBStoredImageRepo(conn) imageRepo := image.NewDBImageRepo(conn) + imageRelRepo := image.NewDBImageRelationshipRepo(conn) appRouter.Use(graph.NewLoadersMiddleware(identityRepo, storageDefRepo, storedImageRepo)) identityManager := NewIdentityManager(identityRepo, sessionPersister) @@ -93,6 +94,7 @@ func MakeServer( storageDefRepo, storedImageRepo, imageRepo, + imageRelRepo, conf.ImageDomain, conf.DefaultURLFormat, getEmailBackend, diff --git a/httpserver/utils.go b/httpserver/utils.go index 833a6ff..3fc25fb 100644 --- a/httpserver/utils.go +++ b/httpserver/utils.go @@ -18,6 +18,7 @@ func NewGqlResolver( storageDefRepo storage.StorageDefRepo, storedImageRepo storage.StoredImageRepo, imageRepo image.ImageRepo, + imageRelRepo image.ImageRelationshipRepo, imageDomain string, defaultURLFormat domainmodels.ImageURLFormat, getEmailBackend func(c context.Context) email.EmailBackend, @@ -30,6 +31,7 @@ func NewGqlResolver( StorageDefRepo: storageDefRepo, StoredImageRepo: storedImageRepo, ImageRepo: imageRepo, + ImageRelRepo: imageRelRepo, ContextUserManager: identityManager.ContextUserManager, LoginFn: identityManager.AuthenticateContext, LogoutFn: identityManager.LogoutContext, diff --git a/image/relationship_repo.go b/image/relationship_repo.go new file mode 100644 index 0000000..a87fe3d --- /dev/null +++ b/image/relationship_repo.go @@ -0,0 +1,262 @@ +//lint:file-ignore ST1001 Allow using dot imports following Jet's convention +package image + +import ( + "context" + "database/sql" + "fmt" + + "github.com/ericls/imgdd/db" + "github.com/ericls/imgdd/db/.gen/imgdd/public/model" + . "github.com/ericls/imgdd/db/.gen/imgdd/public/table" + + . "github.com/go-jet/jet/v2/postgres" + "github.com/google/uuid" +) + +const ( + RelationshipTypeBase = "base" + RelationshipTypeOverlay = "overlay" +) + +type ImageRelationship struct { + Id string + ImageId string + ParentImageId string + RelationshipType string +} + +type ImageRelationshipRepo interface { + CreateRelationship(imageId, parentImageId, relationshipType string) (*ImageRelationship, error) + GetParentsByImageId(imageId string) ([]ImageRelationship, error) + GetChildrenByImageId(imageId string) ([]ImageRelationship, error) + HasRelationships(imageId string) (bool, error) + // GetDescendantIds returns all transitive children of the given image (not including itself). + GetDescendantIds(imageId string) ([]string, error) + // GetAncestorIds returns all transitive parents of the given image (not including itself). + GetAncestorIds(imageId string) ([]string, error) + // AreRelated returns true if there is any path between the two images in the DAG. + AreRelated(imageId1, imageId2 string) (bool, error) + // IsAncestor returns true if ancestorId is a transitive parent of imageId. + IsAncestor(imageId, ancestorId string) (bool, error) +} + +type DBImageRelationshipRepo struct { + db.RepoConn +} + +func (repo *DBImageRelationshipRepo) WithTransaction(tx *sql.Tx) db.DBRepo { + return &DBImageRelationshipRepo{ + RepoConn: repo.RepoConn.WithTransaction(tx), + } +} + +func NewDBImageRelationshipRepo(conn *sql.DB) *DBImageRelationshipRepo { + return &DBImageRelationshipRepo{ + RepoConn: db.NewRepoConn(conn), + } +} + +func (repo *DBImageRelationshipRepo) CreateRelationship(imageId, parentImageId, relationshipType string) (*ImageRelationship, error) { + if imageId == parentImageId { + return nil, fmt.Errorf("an image cannot be its own parent") + } + + // Cycle detection: if imageId is already an ancestor of parentImageId, + // adding parentImageId as a parent of imageId would create a cycle. + isAnc, err := repo.IsAncestor(parentImageId, imageId) + if err != nil { + return nil, fmt.Errorf("cycle detection failed: %w", err) + } + if isAnc { + return nil, fmt.Errorf("cannot create relationship: would form a cycle") + } + + stmt := ImageParentTable.INSERT( + ImageParentTable.ImageID, + ImageParentTable.ParentImageID, + ImageParentTable.RelationshipType, + ).VALUES( + UUID(uuid.MustParse(imageId)), + UUID(uuid.MustParse(parentImageId)), + relationshipType, + ).RETURNING( + ImageParentTable.AllColumns, + ) + + dest := model.ImageParentTable{} + err = stmt.Query(repo.DB, &dest) + if err != nil { + return nil, fmt.Errorf("failed to create image relationship: %w", err) + } + return &ImageRelationship{ + Id: dest.ID.String(), + ImageId: dest.ImageID.String(), + ParentImageId: dest.ParentImageID.String(), + RelationshipType: dest.RelationshipType, + }, nil +} + +func (repo *DBImageRelationshipRepo) GetParentsByImageId(imageId string) ([]ImageRelationship, error) { + stmt := ImageParentTable.SELECT( + ImageParentTable.AllColumns, + ).FROM( + ImageParentTable, + ).WHERE( + ImageParentTable.ImageID.EQ(UUID(uuid.MustParse(imageId))), + ).ORDER_BY( + ImageParentTable.CreatedAt.ASC(), + ) + + var dest []model.ImageParentTable + err := stmt.Query(repo.DB, &dest) + if err != nil { + return nil, err + } + return mapRelationships(dest), nil +} + +func (repo *DBImageRelationshipRepo) GetChildrenByImageId(imageId string) ([]ImageRelationship, error) { + stmt := ImageParentTable.SELECT( + ImageParentTable.AllColumns, + ).FROM( + ImageParentTable, + ).WHERE( + ImageParentTable.ParentImageID.EQ(UUID(uuid.MustParse(imageId))), + ).ORDER_BY( + ImageParentTable.CreatedAt.ASC(), + ) + + var dest []model.ImageParentTable + err := stmt.Query(repo.DB, &dest) + if err != nil { + return nil, err + } + return mapRelationships(dest), nil +} + +func (repo *DBImageRelationshipRepo) HasRelationships(imageId string) (bool, error) { + id := UUID(uuid.MustParse(imageId)) + stmt := ImageParentTable.SELECT( + ImageParentTable.ID, + ).FROM( + ImageParentTable, + ).WHERE( + ImageParentTable.ImageID.EQ(id).OR(ImageParentTable.ParentImageID.EQ(id)), + ).LIMIT(1) + + var dest []model.ImageParentTable + err := stmt.Query(repo.DB, &dest) + if err != nil { + return false, err + } + return len(dest) > 0, nil +} + +// GetDescendantIds walks the DAG downward from imageId using a recursive CTE. +func (repo *DBImageRelationshipRepo) GetDescendantIds(imageId string) ([]string, error) { + query := ` + WITH RECURSIVE descendants AS ( + SELECT image_id FROM image_parent_table WHERE parent_image_id = $1 + UNION + SELECT ip.image_id FROM image_parent_table ip + INNER JOIN descendants d ON ip.parent_image_id = d.image_id + ) + SELECT image_id FROM descendants + ` + return repo.queryIds(query, imageId) +} + +// GetAncestorIds walks the DAG upward from imageId using a recursive CTE. +func (repo *DBImageRelationshipRepo) GetAncestorIds(imageId string) ([]string, error) { + query := ` + WITH RECURSIVE ancestors AS ( + SELECT parent_image_id FROM image_parent_table WHERE image_id = $1 + UNION + SELECT ip.parent_image_id FROM image_parent_table ip + INNER JOIN ancestors a ON ip.image_id = a.parent_image_id + ) + SELECT parent_image_id FROM ancestors + ` + return repo.queryIds(query, imageId) +} + +// AreRelated returns true if there is any DAG path between the two images (in either direction). +func (repo *DBImageRelationshipRepo) AreRelated(imageId1, imageId2 string) (bool, error) { + // Check if imageId2 is an ancestor or descendant of imageId1. + // We walk both directions from imageId1 and check for imageId2. + query := ` + WITH RECURSIVE + descendants AS ( + SELECT image_id AS id FROM image_parent_table WHERE parent_image_id = $1 + UNION + SELECT ip.image_id FROM image_parent_table ip + INNER JOIN descendants d ON ip.parent_image_id = d.id + ), + ancestors AS ( + SELECT parent_image_id AS id FROM image_parent_table WHERE image_id = $1 + UNION + SELECT ip.parent_image_id FROM image_parent_table ip + INNER JOIN ancestors a ON ip.image_id = a.id + ) + SELECT 1 WHERE EXISTS ( + SELECT 1 FROM descendants WHERE id = $2 + UNION ALL + SELECT 1 FROM ancestors WHERE id = $2 + ) + ` + return repo.queryExists(query, imageId1, imageId2) +} + +// IsAncestor returns true if ancestorId is a transitive parent of imageId. +func (repo *DBImageRelationshipRepo) IsAncestor(imageId, ancestorId string) (bool, error) { + query := ` + WITH RECURSIVE ancestors AS ( + SELECT parent_image_id FROM image_parent_table WHERE image_id = $1 + UNION + SELECT ip.parent_image_id FROM image_parent_table ip + INNER JOIN ancestors a ON ip.image_id = a.parent_image_id + ) + SELECT 1 WHERE EXISTS (SELECT 1 FROM ancestors WHERE parent_image_id = $2) + ` + return repo.queryExists(query, imageId, ancestorId) +} + +func (repo *DBImageRelationshipRepo) queryIds(query string, imageId string) ([]string, error) { + rows, err := repo.DB.QueryContext(context.Background(), query, imageId) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (repo *DBImageRelationshipRepo) queryExists(query string, args ...interface{}) (bool, error) { + rows, err := repo.DB.QueryContext(context.Background(), query, args...) + if err != nil { + return false, err + } + defer rows.Close() + return rows.Next(), rows.Err() +} + +func mapRelationships(rows []model.ImageParentTable) []ImageRelationship { + result := make([]ImageRelationship, len(rows)) + for i, r := range rows { + result[i] = ImageRelationship{ + Id: r.ID.String(), + ImageId: r.ImageID.String(), + ParentImageId: r.ParentImageID.String(), + RelationshipType: r.RelationshipType, + } + } + return result +} diff --git a/web_client/src/common/ImageGallery/render.tsx b/web_client/src/common/ImageGallery/render.tsx index d7772e0..54fb16c 100644 --- a/web_client/src/common/ImageGallery/render.tsx +++ b/web_client/src/common/ImageGallery/render.tsx @@ -90,8 +90,6 @@ export function ImageItemRenderer({ /> ) : null; - console.log(showCreatorInfo, image); - return (
-

+

{t("imageEditor.title", { name: image.name })}

diff --git a/web_client/src/site-admin/pages/users/UserImageGallery.tsx b/web_client/src/site-admin/pages/users/UserImageGallery.tsx index 0267e11..1b58530 100644 --- a/web_client/src/site-admin/pages/users/UserImageGallery.tsx +++ b/web_client/src/site-admin/pages/users/UserImageGallery.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useParams } from "react-router"; -import { DEFAULT_MENU_CONFIG } from "~src/common/ImageGallery/menu"; +import { ADMIN_MENU_CONFIG } from "~src/common/ImageGallery/menu"; import { ImageGallery } from "~src/common/ImageGallery/render"; import { useTranslation } from "react-i18next"; import { useQuery } from "@apollo/client/react"; @@ -46,7 +46,7 @@ export function UserImageGallery() {
- +
); From dd4d0bcb3d56efee815751b5d1d073b135c26c25 Mon Sep 17 00:00:00 2001 From: shen Date: Mon, 1 Jun 2026 22:27:57 -0400 Subject: [PATCH 09/15] no queries in loops in Lineage resolver --- graph/images.resolvers.go | 53 ++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/graph/images.resolvers.go b/graph/images.resolvers.go index d18a6b4..149de82 100644 --- a/graph/images.resolvers.go +++ b/graph/images.resolvers.go @@ -91,36 +91,59 @@ func (r *imageResolver) Changes(ctx context.Context, obj *model.Image) (*string, // Lineage is the resolver for the lineage field. // Returns the chain of ancestors from root to this image (inclusive), // walking "base" parent relationships via the DAG table. +// Uses 3 queries total: ancestor IDs (recursive CTE), batch image fetch, batch relationship fetch. func (r *imageResolver) Lineage(ctx context.Context, obj *model.Image) ([]*model.Image, error) { - // Walk up the base-parent chain collecting ancestors (max 100) + // 1. Get all ancestor IDs in one recursive CTE query + ancestorIds, err := r.ImageRelRepo.GetAncestorIds(obj.ID) + if err != nil { + return nil, err + } + if len(ancestorIds) == 0 { + return []*model.Image{obj}, nil + } + + // 2. Batch fetch all ancestor images + ancestors, err := r.ImageRepo.GetImagesByIds(ancestorIds) + if err != nil { + return nil, err + } + imageById := make(map[string]*domainmodels.Image, len(ancestors)) + for _, img := range ancestors { + if img != nil { + imageById[img.Id] = img + } + } + + // 3. Batch fetch relationships for all ancestors + current image + allIds := append(ancestorIds, obj.ID) + relsByImageId, err := r.ImageRelRepo.GetParentsByImageIds(allIds) + if err != nil { + return nil, err + } + + // 4. Reconstruct the base-parent chain by walking from current image to root const maxDepth = 100 - var chain []*model.Image - chain = append(chain, obj) + chain := []*model.Image{obj} currentId := obj.ID + visited := map[string]bool{obj.ID: true} for len(chain) < maxDepth { - parents, err := r.ImageRelRepo.GetParentsByImageId(currentId) - if err != nil { - return nil, err - } - // Follow the "base" parent + rels := relsByImageId[currentId] var baseParentId string - for _, rel := range parents { + for _, rel := range rels { if rel.RelationshipType == image.RelationshipTypeBase { baseParentId = rel.ParentImageId break } } - if baseParentId == "" { + if baseParentId == "" || visited[baseParentId] { break } - parent, err := r.ImageRepo.GetImageById(baseParentId) - if err != nil { - return nil, err - } - if parent == nil { + parent, ok := imageById[baseParentId] + if !ok || parent == nil { break } chain = append(chain, model.FromImage(parent)) + visited[baseParentId] = true currentId = baseParentId } From 88223f7887637066d99ef5ad79e8d4dc2026cf7a Mon Sep 17 00:00:00 2001 From: shen Date: Mon, 1 Jun 2026 22:33:39 -0400 Subject: [PATCH 10/15] respect device pixel ratio --- web_client/src/editor/EditorCanvas.tsx | 29 ++++++++++++++++---------- web_client/src/editor/ImageDetail.tsx | 5 +++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/web_client/src/editor/EditorCanvas.tsx b/web_client/src/editor/EditorCanvas.tsx index 4608b76..14078fe 100644 --- a/web_client/src/editor/EditorCanvas.tsx +++ b/web_client/src/editor/EditorCanvas.tsx @@ -51,21 +51,28 @@ export function EditorCanvas({ // Size canvas to fit container while maintaining aspect ratio const container = canvas.parentElement; if (!container) return; - const maxW = container.clientWidth; + const dpr = window.devicePixelRatio || 1; + const maxCssW = container.clientWidth; const ratio = baseImg.naturalWidth / baseImg.naturalHeight; - const displayW = Math.min(maxW, baseImg.naturalWidth); - const displayH = displayW / ratio; - - canvas.width = displayW; - canvas.height = displayH; + // CSS display size accounts for DPR + const cssW = Math.min(maxCssW, Math.round(baseImg.naturalWidth / dpr)); + const cssH = Math.round(cssW / ratio); + // Canvas buffer renders at full resolution for sharpness + const bufferW = Math.round(cssW * dpr); + const bufferH = Math.round(cssH * dpr); + + canvas.width = bufferW; + canvas.height = bufferH; + canvas.style.width = cssW + "px"; + canvas.style.height = cssH + "px"; // Draw base - ctx.clearRect(0, 0, displayW, displayH); - ctx.drawImage(baseImg, 0, 0, displayW, displayH); + ctx.clearRect(0, 0, bufferW, bufferH); + ctx.drawImage(baseImg, 0, 0, bufferW, bufferH); // Draw overlay if (overlay.image) { - const shortSide = Math.min(displayW, displayH); + const shortSide = Math.min(bufferW, bufferH); const targetSize = shortSide * overlay.scale; const overlayRatio = overlay.image.naturalWidth / overlay.image.naturalHeight; @@ -78,8 +85,8 @@ export function EditorCanvas({ ow = targetSize * overlayRatio; } - const px = overlay.x * displayW; - const py = overlay.y * displayH; + const px = overlay.x * bufferW; + const py = overlay.y * bufferH; // Apply anchor offset to match backend behavior let ox: number, oy: number; diff --git a/web_client/src/editor/ImageDetail.tsx b/web_client/src/editor/ImageDetail.tsx index bc47e82..e1eacfd 100644 --- a/web_client/src/editor/ImageDetail.tsx +++ b/web_client/src/editor/ImageDetail.tsx @@ -105,6 +105,11 @@ export function ImageDetail() { src={absoluteURL(image.url)} alt={image.name} className="max-w-full h-auto rounded mx-auto block" + style={{ + width: Math.round( + image.nominalWidth / (window.devicePixelRatio || 1), + ), + }} />
From b62e7d9e7c9dd61783fb533c6348fa46990f43c9 Mon Sep 17 00:00:00 2001 From: shen Date: Mon, 1 Jun 2026 22:38:09 -0400 Subject: [PATCH 11/15] thiner resolver --- editing/changeset.go | 37 +++++++++++++++++++++ editing/watermark.go | 21 ++++++++++++ graph/images.resolvers.go | 67 ++++++++------------------------------- 3 files changed, 71 insertions(+), 54 deletions(-) diff --git a/editing/changeset.go b/editing/changeset.go index 39f1f98..ec4e2f4 100644 --- a/editing/changeset.go +++ b/editing/changeset.go @@ -18,6 +18,43 @@ type Editor interface { Apply(base []byte, cs ChangeSet, fetchImage FetchImageFunc) ([]byte, string, error) } +// ApplyResult holds the output of applying a ChangeSet. +type ApplyResult struct { + Bytes []byte + MIMEType string + ChangesJSON []byte +} + +// ApplyChangeSet orchestrates applying a change set: looks up the editor, +// fetches the base image bytes, applies the edit, and serializes the changes. +func ApplyChangeSet(cs ChangeSet, baseImageId string, fetchImage FetchImageFunc) (*ApplyResult, error) { + editor, err := GetEditor(cs.Type) + if err != nil { + return nil, err + } + + baseBytes, err := fetchImage(baseImageId) + if err != nil { + return nil, fmt.Errorf("failed to fetch base image: %w", err) + } + + resultBytes, resultMime, err := editor.Apply(baseBytes, cs, fetchImage) + if err != nil { + return nil, fmt.Errorf("failed to apply %s: %w", cs.Type, err) + } + + changesJSON, err := json.Marshal(cs) + if err != nil { + return nil, fmt.Errorf("failed to serialize changes: %w", err) + } + + return &ApplyResult{ + Bytes: resultBytes, + MIMEType: resultMime, + ChangesJSON: changesJSON, + }, nil +} + var registry = map[string]Editor{} func Register(changeType string, editor Editor) { diff --git a/editing/watermark.go b/editing/watermark.go index 7b66430..b5ceca8 100644 --- a/editing/watermark.go +++ b/editing/watermark.go @@ -46,6 +46,27 @@ type WatermarkParams struct { Scale float64 `json:"scale"` } +// NewWatermarkChangeSet validates params and builds a ChangeSet. +func NewWatermarkChangeSet(params WatermarkParams) (ChangeSet, error) { + if params.Opacity < 0 || params.Opacity > 1 { + return ChangeSet{}, fmt.Errorf("opacity must be between 0 and 1") + } + if params.Scale <= 0 || params.Scale > 1 { + return ChangeSet{}, fmt.Errorf("scale must be between 0 (exclusive) and 1") + } + if params.Position.X < 0 || params.Position.X > 1 || params.Position.Y < 0 || params.Position.Y > 1 { + return ChangeSet{}, fmt.Errorf("position values must be between 0 and 1") + } + paramsJSON, err := json.Marshal(params) + if err != nil { + return ChangeSet{}, fmt.Errorf("failed to serialize params: %w", err) + } + return ChangeSet{ + Type: "watermark", + Params: paramsJSON, + }, nil +} + type WatermarkEditor struct{} func (e *WatermarkEditor) Apply(baseBytes []byte, cs ChangeSet, fetchImage FetchImageFunc) ([]byte, string, error) { diff --git a/graph/images.resolvers.go b/graph/images.resolvers.go index 149de82..d49c198 100644 --- a/graph/images.resolvers.go +++ b/graph/images.resolvers.go @@ -7,7 +7,6 @@ package graph import ( "context" - "encoding/json" "fmt" "github.com/ericls/imgdd/domainmodels" @@ -237,17 +236,6 @@ func (r *mutationResolver) ApplyWatermark(ctx context.Context, input model.Apply return nil, fmt.Errorf("unauthorized") } - // Validate input ranges - if input.Position.X < 0 || input.Position.X > 1 || input.Position.Y < 0 || input.Position.Y > 1 { - return nil, fmt.Errorf("position values must be between 0 and 1") - } - if input.Opacity < 0 || input.Opacity > 1 { - return nil, fmt.Errorf("opacity must be between 0 and 1") - } - if input.Scale <= 0 || input.Scale > 1 { - return nil, fmt.Errorf("scale must be between 0 (exclusive) and 1") - } - // Map GraphQL anchor to editing anchor anchorMap := map[model.Anchor]editing.Anchor{ model.AnchorTopLeft: editing.AnchorTopLeft, @@ -257,53 +245,24 @@ func (r *mutationResolver) ApplyWatermark(ctx context.Context, input model.Apply model.AnchorCenter: editing.AnchorCenter, } - params := editing.WatermarkParams{ + cs, err := editing.NewWatermarkChangeSet(editing.WatermarkParams{ OverlayImageID: input.OverlayImageID, - Position: editing.WatermarkPosition{ - X: input.Position.X, - Y: input.Position.Y, - }, - Anchor: anchorMap[input.Anchor], - Opacity: input.Opacity, - Scale: input.Scale, - } - paramsJSON, err := json.Marshal(params) - if err != nil { - return nil, fmt.Errorf("failed to serialize params: %w", err) - } - - cs := editing.ChangeSet{ - Type: "watermark", - Params: paramsJSON, - } - - // Get editor - editor, err := editing.GetEditor(cs.Type) + Position: editing.WatermarkPosition{X: input.Position.X, Y: input.Position.Y}, + Anchor: anchorMap[input.Anchor], + Opacity: input.Opacity, + Scale: input.Scale, + }) if err != nil { return nil, err } - // Fetch base image bytes fetchImage := editing.NewFetchImageFunc(r.StoredImageRepo, r.StorageDefRepo) - baseBytes, err := fetchImage(input.BaseImageID) - if err != nil { - return nil, fmt.Errorf("failed to fetch base image: %w", err) - } - - // Apply the edit - resultBytes, resultMime, err := editor.Apply(baseBytes, cs, fetchImage) + result, err := editing.ApplyChangeSet(cs, input.BaseImageID, fetchImage) if err != nil { - return nil, fmt.Errorf("failed to apply watermark: %w", err) - } - - // Serialize the change set for storage - changesJSON, err := json.Marshal(cs) - if err != nil { - return nil, fmt.Errorf("failed to serialize changes: %w", err) + return nil, err } - // Get dimensions of the result - width, height, err := utils.GetImageDimensions(resultBytes) + width, height, err := utils.GetImageDimensions(result.Bytes) if err != nil { return nil, fmt.Errorf("failed to get result dimensions: %w", err) } @@ -334,15 +293,15 @@ func (r *mutationResolver) ApplyWatermark(ctx context.Context, input model.Apply Identifier: uuid.New().String(), Name: baseImage.Name, ParentId: baseImage.Id, - Changes: string(changesJSON), + Changes: string(result.ChangesJSON), CreatedById: currentUser.Id, - MIMEType: resultMime, + MIMEType: result.MIMEType, NominalWidth: width, NominalHeight: height, - NominalByteSize: int32(len(resultBytes)), + NominalByteSize: int32(len(result.Bytes)), } - storedImage, err := r.ImageRepo.CreateAndSaveUploadedImage(&newImage, resultMime, resultBytes, storageDef.Id, storageInstance.Save) + storedImage, err := r.ImageRepo.CreateAndSaveUploadedImage(&newImage, result.MIMEType, result.Bytes, storageDef.Id, storageInstance.Save) if err != nil { return nil, fmt.Errorf("failed to save result image: %w", err) } From 18424ba1395ca070f259dfbad7f04595f0611efb Mon Sep 17 00:00:00 2001 From: shen Date: Mon, 1 Jun 2026 22:51:58 -0400 Subject: [PATCH 12/15] obviously should be in an transaction --- graph/graph_test.go | 1 + graph/images.resolvers.go | 23 ++++++++++++++++++----- graph/resolver.go | 2 ++ httpserver/make_server.go | 1 + httpserver/utils.go | 3 +++ 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/graph/graph_test.go b/graph/graph_test.go index 631c27a..9507a3a 100644 --- a/graph/graph_test.go +++ b/graph/graph_test.go @@ -94,6 +94,7 @@ func newTestContext(tObj *testing.T) *TestContext { imageRelRepo := image.NewDBImageRelationshipRepo(conn) dummyEmailBackend := email.NewDummyBackend() resolver := httpserver.NewGqlResolver( + conn, identityManager, storageDefRepo, storedImageRepo, diff --git a/graph/images.resolvers.go b/graph/images.resolvers.go index d49c198..c2bd6de 100644 --- a/graph/images.resolvers.go +++ b/graph/images.resolvers.go @@ -9,6 +9,7 @@ import ( "context" "fmt" + "github.com/ericls/imgdd/db" "github.com/ericls/imgdd/domainmodels" "github.com/ericls/imgdd/editing" "github.com/ericls/imgdd/graph/model" @@ -288,7 +289,16 @@ func (r *mutationResolver) ApplyWatermark(ctx context.Context, input model.Apply return nil, fmt.Errorf("failed to get storage: %w", err) } - // Create the new derived image + // Create image and relationships in a single transaction + tx, err := r.DBConn.Begin() + if err != nil { + return nil, fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + txImageRepo := r.ImageRepo.(db.DBRepo).WithTransaction(tx).(*image.DBImageRepo) + txImageRelRepo := r.ImageRelRepo.(db.DBRepo).WithTransaction(tx).(*image.DBImageRelationshipRepo) + newImage := domainmodels.Image{ Identifier: uuid.New().String(), Name: baseImage.Name, @@ -301,20 +311,23 @@ func (r *mutationResolver) ApplyWatermark(ctx context.Context, input model.Apply NominalByteSize: int32(len(result.Bytes)), } - storedImage, err := r.ImageRepo.CreateAndSaveUploadedImage(&newImage, result.MIMEType, result.Bytes, storageDef.Id, storageInstance.Save) + storedImage, err := txImageRepo.CreateAndSaveUploadedImage(&newImage, result.MIMEType, result.Bytes, storageDef.Id, storageInstance.Save) if err != nil { return nil, fmt.Errorf("failed to save result image: %w", err) } - // Record DAG relationships newImageId := storedImage.Image.Id - if _, err := r.ImageRelRepo.CreateRelationship(newImageId, input.BaseImageID, image.RelationshipTypeBase); err != nil { + if _, err := txImageRelRepo.CreateRelationship(newImageId, input.BaseImageID, image.RelationshipTypeBase); err != nil { return nil, fmt.Errorf("failed to create base relationship: %w", err) } - if _, err := r.ImageRelRepo.CreateRelationship(newImageId, input.OverlayImageID, image.RelationshipTypeOverlay); err != nil { + if _, err := txImageRelRepo.CreateRelationship(newImageId, input.OverlayImageID, image.RelationshipTypeOverlay); err != nil { return nil, fmt.Errorf("failed to create overlay relationship: %w", err) } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("failed to commit transaction: %w", err) + } + return &model.ApplyWatermarkResult{ Image: model.FromImage(storedImage.Image), }, nil diff --git a/graph/resolver.go b/graph/resolver.go index 825cad9..2abf60c 100644 --- a/graph/resolver.go +++ b/graph/resolver.go @@ -2,6 +2,7 @@ package graph import ( "context" + "database/sql" "net/url" "github.com/ericls/imgdd/captcha" @@ -18,6 +19,7 @@ import ( // It serves as dependency injection for your app, add any dependencies you require here. type Resolver struct { + DBConn *sql.DB IdentityRepo identity.IdentityRepo StorageDefRepo storage.StorageDefRepo StoredImageRepo storage.StoredImageRepo diff --git a/httpserver/make_server.go b/httpserver/make_server.go index d27a15f..a920e60 100644 --- a/httpserver/make_server.go +++ b/httpserver/make_server.go @@ -90,6 +90,7 @@ func MakeServer( captchaClient := captcha.MakeClient(conf.CaptchaProvider, conf.RecaptchaServerKey, conf.TurnstileSecretKey) gqlResolver := NewGqlResolver( + conn, identityManager, storageDefRepo, storedImageRepo, diff --git a/httpserver/utils.go b/httpserver/utils.go index 3fc25fb..e3ce57c 100644 --- a/httpserver/utils.go +++ b/httpserver/utils.go @@ -2,6 +2,7 @@ package httpserver import ( "context" + "database/sql" "github.com/ericls/imgdd/captcha" "github.com/ericls/imgdd/domainmodels" @@ -14,6 +15,7 @@ import ( type ContextKey string func NewGqlResolver( + dbConn *sql.DB, identityManager *IdentityManager, storageDefRepo storage.StorageDefRepo, storedImageRepo storage.StoredImageRepo, @@ -27,6 +29,7 @@ func NewGqlResolver( allowNewUser bool, ) *graph.Resolver { return &graph.Resolver{ + DBConn: dbConn, IdentityRepo: identityManager.IdentityRepo, StorageDefRepo: storageDefRepo, StoredImageRepo: storedImageRepo, From 683c2f5e6d99ad8873f98f02aa01f648847d330c Mon Sep 17 00:00:00 2001 From: shen Date: Mon, 1 Jun 2026 23:20:36 -0400 Subject: [PATCH 13/15] Address comments --- graph/images.resolvers.go | 38 ++++++--------- image/relationship_repo.go | 46 ++++++++++++++----- web_client/src/common/ImageGallery/menu.tsx | 12 +++-- web_client/src/common/ImageGallery/render.tsx | 34 ++++++-------- web_client/src/uploader/uploader.tsx | 5 +- 5 files changed, 77 insertions(+), 58 deletions(-) diff --git a/graph/images.resolvers.go b/graph/images.resolvers.go index c2bd6de..7f765a5 100644 --- a/graph/images.resolvers.go +++ b/graph/images.resolvers.go @@ -48,31 +48,15 @@ func (r *imageResolver) URL(ctx context.Context, obj *model.Image) (string, erro // Root is the resolver for the root field. // Walks up the "base" parent chain to find the ultimate ancestor. func (r *imageResolver) Root(ctx context.Context, obj *model.Image) (*model.Image, error) { - ancestors, err := r.ImageRelRepo.GetAncestorIds(obj.ID) + lineage, err := r.Lineage(ctx, obj) if err != nil { return nil, err } - if len(ancestors) == 0 { + // Lineage returns [root, ..., current]. If only current, no root. + if len(lineage) <= 1 { return nil, nil } - // The last ancestor in the list is the most distant (root) - // But GetAncestorIds returns unordered, so find the one with no parents - for _, id := range ancestors { - parents, err := r.ImageRelRepo.GetParentsByImageId(id) - if err != nil { - return nil, err - } - if len(parents) == 0 { - root, err := r.ImageRepo.GetImageById(id) - if err != nil { - return nil, err - } - if root != nil { - return model.FromImage(root), nil - } - } - } - return nil, nil + return lineage[0], nil } // Parent is the resolver for the parent field. @@ -237,6 +221,10 @@ func (r *mutationResolver) ApplyWatermark(ctx context.Context, input model.Apply return nil, fmt.Errorf("unauthorized") } + if input.Position == nil { + return nil, fmt.Errorf("position is required") + } + // Map GraphQL anchor to editing anchor anchorMap := map[model.Anchor]editing.Anchor{ model.AnchorTopLeft: editing.AnchorTopLeft, @@ -245,11 +233,15 @@ func (r *mutationResolver) ApplyWatermark(ctx context.Context, input model.Apply model.AnchorBottomRight: editing.AnchorBottomRight, model.AnchorCenter: editing.AnchorCenter, } + anchor, ok := anchorMap[input.Anchor] + if !ok { + return nil, fmt.Errorf("invalid anchor: %s", input.Anchor) + } cs, err := editing.NewWatermarkChangeSet(editing.WatermarkParams{ OverlayImageID: input.OverlayImageID, Position: editing.WatermarkPosition{X: input.Position.X, Y: input.Position.Y}, - Anchor: anchorMap[input.Anchor], + Anchor: anchor, Opacity: input.Opacity, Scale: input.Scale, }) @@ -348,8 +340,8 @@ func (r *viewerResolver) Image(ctx context.Context, obj *model.Viewer, id string if err != nil || img == nil { return nil, fmt.Errorf("image not found") } - // TODO: make permission checks more structured. - if img.CreatedById != currentUser.Id && !currentUser.IsSiteOwner() { + createdBy := r.IdentityRepo.GetOrganizationUserById(img.CreatedById) + if !currentUser.CanManage(createdBy) { return nil, fmt.Errorf("unauthorized") } diff --git a/image/relationship_repo.go b/image/relationship_repo.go index fdedcdd..415b98f 100644 --- a/image/relationship_repo.go +++ b/image/relationship_repo.go @@ -73,13 +73,22 @@ func (repo *DBImageRelationshipRepo) CreateRelationship(imageId, parentImageId, return nil, fmt.Errorf("cannot create relationship: would form a cycle") } + parsedImageId, err := uuid.Parse(imageId) + if err != nil { + return nil, fmt.Errorf("invalid image ID: %w", err) + } + parsedParentId, err := uuid.Parse(parentImageId) + if err != nil { + return nil, fmt.Errorf("invalid parent image ID: %w", err) + } + stmt := ImageParentTable.INSERT( ImageParentTable.ImageID, ImageParentTable.ParentImageID, ImageParentTable.RelationshipType, ).VALUES( - UUID(uuid.MustParse(imageId)), - UUID(uuid.MustParse(parentImageId)), + UUID(parsedImageId), + UUID(parsedParentId), relationshipType, ).RETURNING( ImageParentTable.AllColumns, @@ -99,19 +108,22 @@ func (repo *DBImageRelationshipRepo) CreateRelationship(imageId, parentImageId, } func (repo *DBImageRelationshipRepo) GetParentsByImageId(imageId string) ([]ImageRelationship, error) { + parsed, err := uuid.Parse(imageId) + if err != nil { + return nil, fmt.Errorf("invalid image ID: %w", err) + } stmt := ImageParentTable.SELECT( ImageParentTable.AllColumns, ).FROM( ImageParentTable, ).WHERE( - ImageParentTable.ImageID.EQ(UUID(uuid.MustParse(imageId))), + ImageParentTable.ImageID.EQ(UUID(parsed)), ).ORDER_BY( ImageParentTable.CreatedAt.ASC(), ) var dest []model.ImageParentTable - err := stmt.Query(repo.DB, &dest) - if err != nil { + if err = stmt.Query(repo.DB, &dest); err != nil { return nil, err } return mapRelationships(dest), nil @@ -123,7 +135,11 @@ func (repo *DBImageRelationshipRepo) GetParentsByImageIds(imageIds []string) (ma } uuids := make([]Expression, len(imageIds)) for i, id := range imageIds { - uuids[i] = UUID(uuid.MustParse(id)) + parsed, err := uuid.Parse(id) + if err != nil { + return nil, fmt.Errorf("invalid image ID %q: %w", id, err) + } + uuids[i] = UUID(parsed) } stmt := ImageParentTable.SELECT( ImageParentTable.AllColumns, @@ -149,26 +165,33 @@ func (repo *DBImageRelationshipRepo) GetParentsByImageIds(imageIds []string) (ma } func (repo *DBImageRelationshipRepo) GetChildrenByImageId(imageId string) ([]ImageRelationship, error) { + parsed, err := uuid.Parse(imageId) + if err != nil { + return nil, fmt.Errorf("invalid image ID: %w", err) + } stmt := ImageParentTable.SELECT( ImageParentTable.AllColumns, ).FROM( ImageParentTable, ).WHERE( - ImageParentTable.ParentImageID.EQ(UUID(uuid.MustParse(imageId))), + ImageParentTable.ParentImageID.EQ(UUID(parsed)), ).ORDER_BY( ImageParentTable.CreatedAt.ASC(), ) var dest []model.ImageParentTable - err := stmt.Query(repo.DB, &dest) - if err != nil { + if err = stmt.Query(repo.DB, &dest); err != nil { return nil, err } return mapRelationships(dest), nil } func (repo *DBImageRelationshipRepo) HasRelationships(imageId string) (bool, error) { - id := UUID(uuid.MustParse(imageId)) + parsed, err := uuid.Parse(imageId) + if err != nil { + return false, fmt.Errorf("invalid image ID: %w", err) + } + id := UUID(parsed) stmt := ImageParentTable.SELECT( ImageParentTable.ID, ).FROM( @@ -178,8 +201,7 @@ func (repo *DBImageRelationshipRepo) HasRelationships(imageId string) (bool, err ).LIMIT(1) var dest []model.ImageParentTable - err := stmt.Query(repo.DB, &dest) - if err != nil { + if err = stmt.Query(repo.DB, &dest); err != nil { return false, err } return len(dest) > 0, nil diff --git a/web_client/src/common/ImageGallery/menu.tsx b/web_client/src/common/ImageGallery/menu.tsx index 6d8981f..317e54c 100644 --- a/web_client/src/common/ImageGallery/menu.tsx +++ b/web_client/src/common/ImageGallery/menu.tsx @@ -9,6 +9,7 @@ import { routes } from "~src/routes"; import { prompt } from "~src/ui/prompt"; import type { i18n as i18nType } from "i18next"; import { Trans, useTranslation } from "react-i18next"; +import { useNavigate, type NavigateFunction } from "react-router"; enum ImageMenuItemName { DETAILS = "details", @@ -60,6 +61,7 @@ export const ADMIN_MENU_CONFIG: ImageItemMenuConfig = { type MenuItemGetterProps = { image: RenderingImageItem; i18n: i18nType; + navigate: NavigateFunction; onDelete?: () => PromiseLike; }; @@ -83,12 +85,13 @@ function getMenuItemByName( function getDetailsMenuItem({ image: { id }, i18n, + navigate, }: MenuItemGetterProps): MenuItem { return { id: ImageMenuItemName.DETAILS, children: i18n.t("imageItem.details", "Details"), action: () => { - window.location.href = routes.profile.image(id); + navigate(routes.profile.image(id)); }, }; } @@ -96,12 +99,13 @@ function getDetailsMenuItem({ function getEditMenuItem({ image: { id }, i18n, + navigate, }: MenuItemGetterProps): MenuItem { return { id: ImageMenuItemName.EDIT, children: i18n.t("imageItem.edit", "Edit"), action: () => { - window.location.href = routes.profile.editImage(id); + navigate(routes.profile.editImage(id)); }, }; } @@ -169,6 +173,7 @@ export function useImageItemMenu( config?: ImageItemMenuConfig, ): MenuSections | null { const { i18n } = useTranslation(); + const navigate = useNavigate(); const { execute: executeDelete } = useDeleteImage(image.id); const menuSections = React.useMemo(() => { if (!config) return null; @@ -178,11 +183,12 @@ export function useImageItemMenu( image, onDelete: executeDelete, i18n, + navigate, }), ); return { id: section.id, items }; }); - }, [image, config, executeDelete, i18n]); + }, [image, config, executeDelete, i18n, navigate]); if (!menuSections) return null; return { children: menuSections, diff --git a/web_client/src/common/ImageGallery/render.tsx b/web_client/src/common/ImageGallery/render.tsx index 0ab8ea9..b6b7f3b 100644 --- a/web_client/src/common/ImageGallery/render.tsx +++ b/web_client/src/common/ImageGallery/render.tsx @@ -97,15 +97,9 @@ export function ImageItemRenderer({ className="group flex flex-col overflow-hidden rounded-md" tabIndex={0} > - +
{menuSections && ( -
e.preventDefault()} - > +
)} - {image.parent && ( - - {t("imageItem.revision")} - - )} - {`preview - + + {image.parent && ( + + {t("imageItem.revision")} + + )} + {`preview + +
{avatarEl && image.createdBy && canLinkToUser ? ( { + if (hasNavigated.current) return; if (!isAuthenticated || uploadingFiles.length !== 1) return; const file = uploadingFiles[0]; if (file.loaded && file.imageId) { - navigate(routes.profile.image(file.imageId)); + hasNavigated.current = true; + navigate(routes.profile.image(file.imageId), { replace: true }); } }, [isAuthenticated, uploadingFiles, navigate]); const handleDrop = React.useCallback( From 743e0d210f0d9a7c76bb6d360fbfd3e086c967ae Mon Sep 17 00:00:00 2001 From: shen Date: Mon, 1 Jun 2026 23:24:30 -0400 Subject: [PATCH 14/15] only show edit button on owned images --- web_client/src/__generated__/gql.ts | 6 +++--- web_client/src/__generated__/graphql.ts | 4 ++-- web_client/src/editor/ImageDetail.tsx | 24 ++++++++++++++++++------ 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/web_client/src/__generated__/gql.ts b/web_client/src/__generated__/gql.ts index c6d3b5f..9bb2606 100644 --- a/web_client/src/__generated__/gql.ts +++ b/web_client/src/__generated__/gql.ts @@ -20,7 +20,7 @@ type Documents = { "\nmutation resetPassword($input: ResetPasswordInput!) {\n resetPassword(input: $input) {\n success\n }\n}\n": typeof types.ResetPasswordDocument, "\n query ImagesQuery(\n $orderBy: ImageOrderByInput\n $filters: ImageFilterInput\n $after: String\n $before: String\n ) {\n viewer {\n id\n images(\n orderBy: $orderBy\n filters: $filters\n after: $after\n before: $before\n ) {\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n totalCount\n currentCount\n }\n edges {\n cursor\n node {\n id\n url\n name\n nominalWidth\n nominalHeight\n nominalByteSize\n createdAt\n storedImages {\n id\n }\n parent {\n id\n name\n }\n createdBy {\n id\n user {\n id\n avatarUrl\n }\n }\n }\n }\n }\n }\n }\n": typeof types.ImagesQueryDocument, "\n mutation DeleteImage($input: DeleteImageInput!) {\n deleteImage(input: $input) {\n id\n }\n }\n": typeof types.DeleteImageDocument, - "\n query ImageDetail($id: ID!) {\n viewer {\n id\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n createdAt\n changes\n lineage {\n id\n url\n name\n changes\n createdAt\n }\n }\n }\n }\n": typeof types.ImageDetailDocument, + "\n query ImageDetail($id: ID!) {\n viewer {\n id\n organizationUser {\n id\n }\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n createdAt\n changes\n createdBy {\n id\n }\n lineage {\n id\n url\n name\n changes\n createdAt\n }\n }\n }\n }\n": typeof types.ImageDetailDocument, "\n query ImageForEditor($id: ID!) {\n viewer {\n id\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n": typeof types.ImageForEditorDocument, "\n mutation ApplyWatermark($input: ApplyWatermarkInput!) {\n applyWatermark(input: $input) {\n image {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n nominalByteSize\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n": typeof types.ApplyWatermarkDocument, "\nquery Auth {\n viewer {\n id\n organizationUser {\n id\n user {\n id\n email\n name\n }\n }\n hasAdminAccess: hasPermission(permission: AdminAccess)\n hasSiteOwnerAccess: hasPermission(permission: SiteOwnerAccess)\n }\n}\n": typeof types.AuthDocument, @@ -41,7 +41,7 @@ const documents: Documents = { "\nmutation resetPassword($input: ResetPasswordInput!) {\n resetPassword(input: $input) {\n success\n }\n}\n": types.ResetPasswordDocument, "\n query ImagesQuery(\n $orderBy: ImageOrderByInput\n $filters: ImageFilterInput\n $after: String\n $before: String\n ) {\n viewer {\n id\n images(\n orderBy: $orderBy\n filters: $filters\n after: $after\n before: $before\n ) {\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n totalCount\n currentCount\n }\n edges {\n cursor\n node {\n id\n url\n name\n nominalWidth\n nominalHeight\n nominalByteSize\n createdAt\n storedImages {\n id\n }\n parent {\n id\n name\n }\n createdBy {\n id\n user {\n id\n avatarUrl\n }\n }\n }\n }\n }\n }\n }\n": types.ImagesQueryDocument, "\n mutation DeleteImage($input: DeleteImageInput!) {\n deleteImage(input: $input) {\n id\n }\n }\n": types.DeleteImageDocument, - "\n query ImageDetail($id: ID!) {\n viewer {\n id\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n createdAt\n changes\n lineage {\n id\n url\n name\n changes\n createdAt\n }\n }\n }\n }\n": types.ImageDetailDocument, + "\n query ImageDetail($id: ID!) {\n viewer {\n id\n organizationUser {\n id\n }\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n createdAt\n changes\n createdBy {\n id\n }\n lineage {\n id\n url\n name\n changes\n createdAt\n }\n }\n }\n }\n": types.ImageDetailDocument, "\n query ImageForEditor($id: ID!) {\n viewer {\n id\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n": types.ImageForEditorDocument, "\n mutation ApplyWatermark($input: ApplyWatermarkInput!) {\n applyWatermark(input: $input) {\n image {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n nominalByteSize\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n": types.ApplyWatermarkDocument, "\nquery Auth {\n viewer {\n id\n organizationUser {\n id\n user {\n id\n email\n name\n }\n }\n hasAdminAccess: hasPermission(permission: AdminAccess)\n hasSiteOwnerAccess: hasPermission(permission: SiteOwnerAccess)\n }\n}\n": types.AuthDocument, @@ -97,7 +97,7 @@ export function gql(source: "\n mutation DeleteImage($input: DeleteImageInput!) /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function gql(source: "\n query ImageDetail($id: ID!) {\n viewer {\n id\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n createdAt\n changes\n lineage {\n id\n url\n name\n changes\n createdAt\n }\n }\n }\n }\n"): (typeof documents)["\n query ImageDetail($id: ID!) {\n viewer {\n id\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n createdAt\n changes\n lineage {\n id\n url\n name\n changes\n createdAt\n }\n }\n }\n }\n"]; +export function gql(source: "\n query ImageDetail($id: ID!) {\n viewer {\n id\n organizationUser {\n id\n }\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n createdAt\n changes\n createdBy {\n id\n }\n lineage {\n id\n url\n name\n changes\n createdAt\n }\n }\n }\n }\n"): (typeof documents)["\n query ImageDetail($id: ID!) {\n viewer {\n id\n organizationUser {\n id\n }\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n createdAt\n changes\n createdBy {\n id\n }\n lineage {\n id\n url\n name\n changes\n createdAt\n }\n }\n }\n }\n"]; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/web_client/src/__generated__/graphql.ts b/web_client/src/__generated__/graphql.ts index 5f70ecc..92a70f6 100644 --- a/web_client/src/__generated__/graphql.ts +++ b/web_client/src/__generated__/graphql.ts @@ -455,7 +455,7 @@ export type ImageDetailQueryVariables = Exact<{ }>; -export type ImageDetailQuery = { __typename?: 'Query', viewer: { __typename?: 'Viewer', id: string, image?: { __typename?: 'Image', id: string, url: string, name: string, identifier: string, nominalWidth: number, nominalHeight: number, MIMEType: string, createdAt: any, changes?: string | null, lineage: Array<{ __typename?: 'Image', id: string, url: string, name: string, changes?: string | null, createdAt: any }> } | null } }; +export type ImageDetailQuery = { __typename?: 'Query', viewer: { __typename?: 'Viewer', id: string, organizationUser?: { __typename?: 'OrganizationUser', id: string } | null, image?: { __typename?: 'Image', id: string, url: string, name: string, identifier: string, nominalWidth: number, nominalHeight: number, MIMEType: string, createdAt: any, changes?: string | null, createdBy?: { __typename?: 'OrganizationUser', id: string } | null, lineage: Array<{ __typename?: 'Image', id: string, url: string, name: string, changes?: string | null, createdAt: any }> } | null } }; export type ImageForEditorQueryVariables = Exact<{ id: Scalars['ID']['input']; @@ -569,7 +569,7 @@ export const SendResetPasswordEmailDocument = {"kind":"Document","definitions":[ export const ResetPasswordDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"resetPassword"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ResetPasswordInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resetPassword"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode; export const ImagesQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ImagesQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ImageOrderByInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filters"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ImageFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"before"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"viewer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orderBy"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"before"},"value":{"kind":"Variable","name":{"kind":"Name","value":"before"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"currentCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"nominalWidth"}},{"kind":"Field","name":{"kind":"Name","value":"nominalHeight"}},{"kind":"Field","name":{"kind":"Name","value":"nominalByteSize"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"storedImages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const DeleteImageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteImage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteImageInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteImage"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; -export const ImageDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ImageDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"viewer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identifier"}},{"kind":"Field","name":{"kind":"Name","value":"nominalWidth"}},{"kind":"Field","name":{"kind":"Name","value":"nominalHeight"}},{"kind":"Field","name":{"kind":"Name","value":"MIMEType"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"changes"}},{"kind":"Field","name":{"kind":"Name","value":"lineage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"changes"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const ImageDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ImageDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"viewer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"organizationUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"image"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identifier"}},{"kind":"Field","name":{"kind":"Name","value":"nominalWidth"}},{"kind":"Field","name":{"kind":"Name","value":"nominalHeight"}},{"kind":"Field","name":{"kind":"Name","value":"MIMEType"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"changes"}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"lineage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"changes"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const ImageForEditorDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ImageForEditor"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"viewer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identifier"}},{"kind":"Field","name":{"kind":"Name","value":"nominalWidth"}},{"kind":"Field","name":{"kind":"Name","value":"nominalHeight"}},{"kind":"Field","name":{"kind":"Name","value":"MIMEType"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"changes"}}]}}]}}]}}]} as unknown as DocumentNode; export const ApplyWatermarkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ApplyWatermark"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ApplyWatermarkInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applyWatermark"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identifier"}},{"kind":"Field","name":{"kind":"Name","value":"nominalWidth"}},{"kind":"Field","name":{"kind":"Name","value":"nominalHeight"}},{"kind":"Field","name":{"kind":"Name","value":"nominalByteSize"}},{"kind":"Field","name":{"kind":"Name","value":"MIMEType"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"changes"}}]}}]}}]}}]} as unknown as DocumentNode; export const AuthDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Auth"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"viewer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"organizationUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"hasAdminAccess"},"name":{"kind":"Name","value":"hasPermission"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"permission"},"value":{"kind":"EnumValue","value":"AdminAccess"}}]},{"kind":"Field","alias":{"kind":"Name","value":"hasSiteOwnerAccess"},"name":{"kind":"Name","value":"hasPermission"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"permission"},"value":{"kind":"EnumValue","value":"SiteOwnerAccess"}}]}]}}]}}]} as unknown as DocumentNode; diff --git a/web_client/src/editor/ImageDetail.tsx b/web_client/src/editor/ImageDetail.tsx index e1eacfd..b2991bb 100644 --- a/web_client/src/editor/ImageDetail.tsx +++ b/web_client/src/editor/ImageDetail.tsx @@ -21,6 +21,9 @@ const ImageDetailDoc = gql(` query ImageDetail($id: ID!) { viewer { id + organizationUser { + id + } image(id: $id) { id url @@ -31,6 +34,9 @@ const ImageDetailDoc = gql(` MIMEType createdAt changes + createdBy { + id + } lineage { id url @@ -56,6 +62,10 @@ export function ImageDetail() { }, [imageId, fetchImage]); const image = data?.viewer.image; + const currentUserId = data?.viewer.organizationUser?.id; + const isOwnImage = !!( + currentUserId && image?.createdBy?.id === currentUserId + ); if (loading) return ; if (error) @@ -81,12 +91,14 @@ export function ImageDetail() { {image.name}
- + {isOwnImage && ( + + )} From a0585cd3904929090e08c78f867681f70b4b6993 Mon Sep 17 00:00:00 2001 From: shen Date: Mon, 1 Jun 2026 23:40:14 -0400 Subject: [PATCH 15/15] more fixes --- graph/images.resolvers.go | 12 ++++++++++-- image/repo.go | 7 ++++++- web_client/src/editor/EditorCanvas.tsx | 11 +++++++++-- web_client/src/editor/ImageEditor.tsx | 6 +++++- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/graph/images.resolvers.go b/graph/images.resolvers.go index 7f765a5..c0dc867 100644 --- a/graph/images.resolvers.go +++ b/graph/images.resolvers.go @@ -288,8 +288,16 @@ func (r *mutationResolver) ApplyWatermark(ctx context.Context, input model.Apply } defer tx.Rollback() - txImageRepo := r.ImageRepo.(db.DBRepo).WithTransaction(tx).(*image.DBImageRepo) - txImageRelRepo := r.ImageRelRepo.(db.DBRepo).WithTransaction(tx).(*image.DBImageRelationshipRepo) + dbImageRepo, ok := r.ImageRepo.(db.DBRepo) + if !ok { + return nil, fmt.Errorf("image repo does not support transactions") + } + dbImageRelRepo, ok := r.ImageRelRepo.(db.DBRepo) + if !ok { + return nil, fmt.Errorf("image relationship repo does not support transactions") + } + txImageRepo := dbImageRepo.WithTransaction(tx).(*image.DBImageRepo) + txImageRelRepo := dbImageRelRepo.WithTransaction(tx).(*image.DBImageRelationshipRepo) newImage := domainmodels.Image{ Identifier: uuid.New().String(), diff --git a/image/repo.go b/image/repo.go index 14c630e..83957f8 100644 --- a/image/repo.go +++ b/image/repo.go @@ -4,6 +4,7 @@ package image import ( "bytes" "database/sql" + "fmt" "github.com/ericls/imgdd/db" "github.com/ericls/imgdd/db/.gen/imgdd/public/model" @@ -86,7 +87,11 @@ func (repo *DBImageRepo) GetImagesByIds(ids []string) ([]*dm.Image, error) { } uuids := make([]Expression, len(ids)) for i, id := range ids { - uuids[i] = UUID(uuid.MustParse(id)) + parsed, err := uuid.Parse(id) + if err != nil { + return nil, fmt.Errorf("invalid image ID %q: %w", id, err) + } + uuids[i] = UUID(parsed) } stmt := ImageTable. SELECT(ImageTable.AllColumns). diff --git a/web_client/src/editor/EditorCanvas.tsx b/web_client/src/editor/EditorCanvas.tsx index 14078fe..eb7003f 100644 --- a/web_client/src/editor/EditorCanvas.tsx +++ b/web_client/src/editor/EditorCanvas.tsx @@ -30,13 +30,20 @@ export function EditorCanvas({ // Load base image React.useEffect(() => { + let cancelled = false; const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => { - baseImgRef.current = img; - setBaseLoaded(true); + if (!cancelled) { + baseImgRef.current = img; + setBaseLoaded(true); + } }; img.src = baseImageUrl; + return () => { + cancelled = true; + setBaseLoaded(false); + }; }, [baseImageUrl]); // Draw canvas diff --git a/web_client/src/editor/ImageEditor.tsx b/web_client/src/editor/ImageEditor.tsx index e7980a9..b687d4c 100644 --- a/web_client/src/editor/ImageEditor.tsx +++ b/web_client/src/editor/ImageEditor.tsx @@ -75,11 +75,15 @@ export function ImageEditor() { if (!settings.overlayImageUrl) { return; } + let cancelled = false; const img = new Image(); img.crossOrigin = "anonymous"; - img.onload = () => setOverlayImg(img); + img.onload = () => { + if (!cancelled) setOverlayImg(img); + }; img.src = absoluteURL(settings.overlayImageUrl); return () => { + cancelled = true; setOverlayImg(null); }; }, [settings.overlayImageUrl]);