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/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/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/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..ec4e2f4 --- /dev/null +++ b/editing/changeset.go @@ -0,0 +1,70 @@ +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) +} + +// 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) { + 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..73bad7a --- /dev/null +++ b/editing/fetch.go @@ -0,0 +1,85 @@ +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() + + 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.go b/editing/watermark.go new file mode 100644 index 0000000..b5ceca8 --- /dev/null +++ b/editing/watermark.go @@ -0,0 +1,205 @@ +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"` +} + +// 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) { + 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..2f6e4c9 --- /dev/null +++ b/editing/watermark_test.go @@ -0,0 +1,289 @@ +package editing + +import ( + "bytes" + "encoding/json" + "fmt" + "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") + } +} + +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/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/dataloaders.go b/graph/dataloaders.go index 6ff46d7..3560f0e 100644 --- a/graph/dataloaders.go +++ b/graph/dataloaders.go @@ -8,6 +8,7 @@ import ( dm "github.com/ericls/imgdd/domainmodels" "github.com/ericls/imgdd/graph/model" "github.com/ericls/imgdd/identity" + "github.com/ericls/imgdd/image" "github.com/ericls/imgdd/storage" "github.com/vikstrous/dataloadgen" @@ -23,6 +24,7 @@ type Loaders struct { StoredImagesLoader *dataloadgen.Loader[string, *model.StoredImage] StoredImagesByImageIdsLoader *dataloadgen.Loader[string, []*model.StoredImage] StorageDefinitionsLoader *dataloadgen.Loader[string, *model.StorageDefinition] + BaseParentByImageIdLoader *dataloadgen.Loader[string, *model.Image] } func makeUserLoader(identityRepo identity.IdentityRepo) func(c context.Context, keys []string) ([]*model.User, []error) { @@ -159,17 +161,67 @@ func makeStorageDefinitionsLoader(storageDefRepo storage.StorageDefRepo) func(c } } -func NewLoaders(identityRepo identity.IdentityRepo, storageDefRepo storage.StorageDefRepo, storedImageRepo storage.StoredImageRepo) *Loaders { +func makeBaseParentByImageIdLoader(imageRelRepo image.ImageRelationshipRepo, imageRepo image.ImageRepo) func(c context.Context, imageIds []string) ([]*model.Image, []error) { + return func(c context.Context, imageIds []string) ([]*model.Image, []error) { + parentsByImageId, err := imageRelRepo.GetParentsByImageIds(imageIds) + if err != nil { + return nil, []error{err} + } + + // Collect all base parent image IDs + parentImageIds := make([]string, 0) + baseParentMap := make(map[string]string, len(imageIds)) // imageId -> parentImageId + for _, imageId := range imageIds { + rels := parentsByImageId[imageId] + for _, rel := range rels { + if rel.RelationshipType == image.RelationshipTypeBase { + baseParentMap[imageId] = rel.ParentImageId + parentImageIds = append(parentImageIds, rel.ParentImageId) + break + } + } + } + + // Batch fetch all parent images + var images []*dm.Image + if len(parentImageIds) > 0 { + images, err = imageRepo.GetImagesByIds(parentImageIds) + if err != nil { + return nil, []error{err} + } + } + imageById := make(map[string]*dm.Image, len(images)) + for _, img := range images { + if img != nil { + imageById[img.Id] = img + } + } + + result := make([]*model.Image, len(imageIds)) + for i, imageId := range imageIds { + if parentId, ok := baseParentMap[imageId]; ok { + if img, ok := imageById[parentId]; ok { + result[i] = model.FromImage(img) + } + } + } + return result, nil + } +} + +func NewLoaders(identityRepo identity.IdentityRepo, storageDefRepo storage.StorageDefRepo, storedImageRepo storage.StoredImageRepo, imageRepo image.ImageRepo, imageRelRepo image.ImageRelationshipRepo) *Loaders { userLoader := dataloadgen.NewLoader(makeUserLoader(identityRepo), dataloadgen.WithWait(time.Millisecond)) organizationUserLoader := dataloadgen.NewLoader(makeOrganizationUserLoader(identityRepo), dataloadgen.WithWait(time.Millisecond)) storageDefinitionsLoader := dataloadgen.NewLoader(makeStorageDefinitionsLoader(storageDefRepo), dataloadgen.WithWait(time.Millisecond)) storedImagesLoader := dataloadgen.NewLoader(makeStoredImagesLoader(storedImageRepo, storageDefinitionsLoader), dataloadgen.WithWait(time.Millisecond)) storedImagesByImageIdsLoader := dataloadgen.NewLoader(makeStoredImagesByImageIdsLoader(storedImageRepo, storedImagesLoader), dataloadgen.WithWait(time.Millisecond)) + baseParentByImageIdLoader := dataloadgen.NewLoader(makeBaseParentByImageIdLoader(imageRelRepo, imageRepo), dataloadgen.WithWait(time.Millisecond)) return &Loaders{ UserLoader: userLoader, OrganizationUserLoader: organizationUserLoader, StoredImagesLoader: storedImagesLoader, StoredImagesByImageIdsLoader: storedImagesByImageIdsLoader, + BaseParentByImageIdLoader: baseParentByImageIdLoader, } } @@ -177,9 +229,11 @@ func NewLoadersMiddleware( identityRepo identity.IdentityRepo, storageDefRepo storage.StorageDefRepo, storedImageRepo storage.StoredImageRepo, + imageRepo image.ImageRepo, + imageRelRepo image.ImageRelationshipRepo, ) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { - l := NewLoaders(identityRepo, storageDefRepo, storedImageRepo) + l := NewLoaders(identityRepo, storageDefRepo, storedImageRepo, imageRepo, imageRelRepo) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := context.WithValue(r.Context(), loadersKey, l) next.ServeHTTP(w, r.WithContext(ctx)) diff --git a/graph/generated.go b/graph/generated.go index 028cfc6..a5c4dff 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,15 +63,18 @@ 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 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 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 +101,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 @@ -195,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 @@ -218,6 +227,9 @@ 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) + 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) @@ -231,6 +243,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) @@ -248,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) @@ -270,6 +284,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 +324,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 @@ -327,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 @@ -357,6 +390,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 +484,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 @@ -834,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 @@ -920,12 +981,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 +1108,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{} @@ -1197,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{} @@ -1307,6 +1392,69 @@ 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 "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) _DeleteImageResult_id(ctx context.Context, field graphql.CollectedField, obj *model.DeleteImageResult) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -1695,6 +1843,167 @@ 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 "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_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 "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_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_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": @@ -1752,6 +2061,12 @@ 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 "lineage": + return ec.fieldContext_Image_lineage(ctx, field) case "revisions": return ec.fieldContext_Image_revisions(ctx, field) case "createdAt": @@ -1943,6 +2258,12 @@ 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 "lineage": + return ec.fieldContext_Image_lineage(ctx, field) case "revisions": return ec.fieldContext_Image_revisions(ctx, field) case "createdAt": @@ -2548,6 +2869,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, @@ -3191,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": @@ -4228,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, @@ -4594,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": @@ -6168,13 +6639,78 @@ func (ec *executionContext) fieldContext___Type_isOneOf(_ context.Context, field return nil, errors.New("field of type Boolean does not have child fields") }, } - return fc, nil + return fc, nil +} + +// endregion **************************** field.gotpl ***************************** + +// 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 } -// endregion **************************** field.gotpl ***************************** - -// region **************************** input.gotpl ***************************** - func (ec *executionContext) unmarshalInputCreateUserWithOrganizationInput(ctx context.Context, obj any) (model.CreateUserWithOrganizationInput, error) { var it model.CreateUserWithOrganizationInput if obj == nil { @@ -6411,6 +6947,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 +7179,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 +7448,108 @@ 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 "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 @@ -7179,6 +7890,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) @@ -8125,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 @@ -8790,6 +9541,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 +9610,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 +10094,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..9507a3a 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,11 +91,15 @@ 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( + conn, identityManager, storageDefRepo, + storedImageRepo, imageRepo, + imageRelRepo, "", domainmodels.ImageURLFormat_CANONICAL, func(c context.Context) email.EmailBackend { @@ -110,7 +115,7 @@ func newTestContext(tObj *testing.T) *TestContext { gqlServer.AddTransport(transport.POST{}) // NOTE: the order of code should be reversed compared to Mux.use handler := identityManager.Middleware(gqlServer) - handler = graph.NewLoadersMiddleware(identityRepo, storageDefRepo, storedImageRepo)(handler) + handler = graph.NewLoadersMiddleware(identityRepo, storageDefRepo, storedImageRepo, imageRepo, imageRelRepo)(handler) handler = httpserver.RWContextMiddleware(handler) handler = sessionPersister.Middleware(handler) server := httptest.NewServer(handler) @@ -122,6 +127,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 9438e1c..c0dc867 100644 --- a/graph/images.resolvers.go +++ b/graph/images.resolvers.go @@ -9,10 +9,15 @@ import ( "context" "fmt" + "github.com/ericls/imgdd/db" "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. @@ -41,9 +46,96 @@ 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) { - // TODO: Implement this - return nil, nil + lineage, err := r.Lineage(ctx, obj) + if err != nil { + return nil, err + } + // Lineage returns [root, ..., current]. If only current, no root. + if len(lineage) <= 1 { + return nil, nil + } + return lineage[0], nil +} + +// Parent is the resolver for the parent field. +func (r *imageResolver) Parent(ctx context.Context, obj *model.Image) (*model.Image, error) { + return LoadersFor(ctx).BaseParentByImageIdLoader.Load(ctx, obj.ID) +} + +// 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 +} + +// 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) { + // 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 + chain := []*model.Image{obj} + currentId := obj.ID + visited := map[string]bool{obj.ID: true} + for len(chain) < maxDepth { + rels := relsByImageId[currentId] + var baseParentId string + for _, rel := range rels { + if rel.RelationshipType == image.RelationshipTypeBase { + baseParentId = rel.ParentImageId + break + } + } + if baseParentId == "" || visited[baseParentId] { + break + } + parent, ok := imageById[baseParentId] + if !ok || parent == nil { + break + } + chain = append(chain, model.FromImage(parent)) + visited[baseParentId] = true + currentId = baseParentId + } + + // 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. @@ -68,6 +160,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 @@ -78,6 +173,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 { @@ -86,6 +189,173 @@ 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 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 { + return nil, fmt.Errorf("base image not found") + } + if baseImage.CreatedById != currentUser.Id { + 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") + } + if overlayImage.CreatedById != currentUser.Id { + 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, + model.AnchorTopRight: editing.AnchorTopRight, + model.AnchorBottomLeft: editing.AnchorBottomLeft, + 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: anchor, + Opacity: input.Opacity, + Scale: input.Scale, + }) + if err != nil { + return nil, err + } + + fetchImage := editing.NewFetchImageFunc(r.StoredImageRepo, r.StorageDefRepo) + result, err := editing.ApplyChangeSet(cs, input.BaseImageID, fetchImage) + if err != nil { + return nil, err + } + + width, height, err := utils.GetImageDimensions(result.Bytes) + 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 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() + + 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(), + Name: baseImage.Name, + ParentId: baseImage.Id, + Changes: string(result.ChangesJSON), + CreatedById: currentUser.Id, + MIMEType: result.MIMEType, + NominalWidth: width, + NominalHeight: height, + NominalByteSize: int32(len(result.Bytes)), + } + + 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) + } + + newImageId := storedImage.Image.Id + if _, err := txImageRelRepo.CreateRelationship(newImageId, input.BaseImageID, image.RelationshipTypeBase); err != nil { + return nil, fmt.Errorf("failed to create base relationship: %w", err) + } + 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 +} + +// 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") + } + createdBy := r.IdentityRepo.GetOrganizationUserById(img.CreatedById) + if !currentUser.CanManage(createdBy) { + 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) @@ -99,6 +369,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/images.resolvers_test.go b/graph/images.resolvers_test.go index aa0fd31..343f3a4 100644 --- a/graph/images.resolvers_test.go +++ b/graph/images.resolvers_test.go @@ -1,10 +1,17 @@ package graph_test import ( + "bytes" + "image" + "image/color" + "image/png" + "os" "testing" "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" "github.com/99designs/gqlgen/client" @@ -460,6 +467,794 @@ 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) + + // 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) { + 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 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 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 tImagesListParentField(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) + + // Create a derived image via watermark + 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]any{ + "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 images list with parent field — exercises the dataloader + var resp struct { + Viewer struct { + Images struct { + Edges []struct { + Node struct { + ID string + Name string + Parent *struct { + ID string + Name string + } + } + } + } + } + } + err = tc.client.Post(` + query { + viewer { + images { + edges { + node { + id + name + parent { + id + name + } + } + } + } + } + }`, &resp) + require.NoError(t, err) + + // Should have 3 images: base, overlay, child + require.Len(t, resp.Viewer.Images.Edges, 3) + + // Build a map for easier assertions + nodeById := map[string]struct { + Name string + Parent *struct { + ID string + Name string + } + }{} + for _, edge := range resp.Viewer.Images.Edges { + nodeById[edge.Node.ID] = struct { + Name string + Parent *struct { + ID string + Name string + } + }{edge.Node.Name, edge.Node.Parent} + } + + // Base and overlay images should have no parent + require.Nil(t, nodeById[baseImage.Id].Parent) + require.Nil(t, nodeById[overlayImage.Id].Parent) + + // Child image should have base image as parent + require.NotNil(t, nodeById[childId].Parent) + require.Equal(t, baseImage.Id, nodeById[childId].Parent.ID) + require.Equal(t, baseImage.Name, nodeById[childId].Parent.Name) +} + func TestImageResolvers(t *testing.T) { tc := newTestContext(t) tc.runTestCases( @@ -471,5 +1266,19 @@ func TestImageResolvers(t *testing.T) { tDeletingImage, tImageCreatedByIsPopulated, tImageCreatedByNullWhenNoCreator, + tApplyWatermark, + tApplyWatermarkUnauthenticated, + tApplyWatermarkUnauthorizedImage, + tApplyWatermarkInvalidImageId, + tViewerImage, + tViewerImageUnauthorized, + tViewerImageInvalidId, + tImageLineageAndRoot, + tImageNoParentLineage, + tDeleteImageBlockedByRelationship, + tDAGNoCycles, + tDAGQueriesDescendantsAncestorsRelated, + tDAGDiamondShape, + tImagesListParentField, ) } 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..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,9 +19,12 @@ 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 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/graph/schema/images.graphqls b/graph/schema/images.graphqls index 1b6c3a6..a1a1c77 100644 --- a/graph/schema/images.graphqls +++ b/graph/schema/images.graphqls @@ -13,6 +13,9 @@ type Image { nominalHeight: Int! nominalByteSize: Int! root: Image + parent: Image + changes: String + lineage: [Image!]! revisions: [Image!]! createdAt: Time! storedImages: [StoredImage!]! @@ -20,6 +23,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 @@ -53,6 +82,7 @@ type ImagesResult { } extend type Viewer { + image(id: ID!): Image @isAuthenticated images( orderBy: ImageOrderByInput filters: ImageFilterInput @@ -71,4 +101,6 @@ type DeleteImageResult { extend type Mutation { deleteImage(input: DeleteImageInput!): DeleteImageResult! @isAuthenticated + applyWatermark(input: ApplyWatermarkInput!): ApplyWatermarkResult! + @isAuthenticated } 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/httpserver/make_server.go b/httpserver/make_server.go index b09df5e..a920e60 100644 --- a/httpserver/make_server.go +++ b/httpserver/make_server.go @@ -75,7 +75,8 @@ func MakeServer( storageDefRepo := storageConf.MakeStorageDefRepo() storedImageRepo := storage.NewDBStoredImageRepo(conn) imageRepo := image.NewDBImageRepo(conn) - appRouter.Use(graph.NewLoadersMiddleware(identityRepo, storageDefRepo, storedImageRepo)) + imageRelRepo := image.NewDBImageRelationshipRepo(conn) + appRouter.Use(graph.NewLoadersMiddleware(identityRepo, storageDefRepo, storedImageRepo, imageRepo, imageRelRepo)) identityManager := NewIdentityManager(identityRepo, sessionPersister) getEmailBackend := func(c context.Context) email.EmailBackend { @@ -89,9 +90,12 @@ func MakeServer( captchaClient := captcha.MakeClient(conf.CaptchaProvider, conf.RecaptchaServerKey, conf.TurnstileSecretKey) gqlResolver := NewGqlResolver( + conn, identityManager, storageDefRepo, + storedImageRepo, imageRepo, + imageRelRepo, conf.ImageDomain, conf.DefaultURLFormat, getEmailBackend, diff --git a/httpserver/utils.go b/httpserver/utils.go index 398c755..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,9 +15,12 @@ import ( type ContextKey string func NewGqlResolver( + dbConn *sql.DB, identityManager *IdentityManager, storageDefRepo storage.StorageDefRepo, + storedImageRepo storage.StoredImageRepo, imageRepo image.ImageRepo, + imageRelRepo image.ImageRelationshipRepo, imageDomain string, defaultURLFormat domainmodels.ImageURLFormat, getEmailBackend func(c context.Context) email.EmailBackend, @@ -25,9 +29,12 @@ func NewGqlResolver( allowNewUser bool, ) *graph.Resolver { return &graph.Resolver{ + DBConn: dbConn, IdentityRepo: identityManager.IdentityRepo, StorageDefRepo: storageDefRepo, + StoredImageRepo: storedImageRepo, ImageRepo: imageRepo, + ImageRelRepo: imageRelRepo, ContextUserManager: identityManager.ContextUserManager, LoginFn: identityManager.AuthenticateContext, LogoutFn: identityManager.LogoutContext, diff --git a/image/interfaces.go b/image/interfaces.go index cdc0561..3a17d3f 100644 --- a/image/interfaces.go +++ b/image/interfaces.go @@ -121,5 +121,6 @@ type ImageRepo interface { ListImages(filtersWithoutCursor *ListImagesFilters, filtersWithCursor *ListImagesFilters, ordering *ListImagesOrdering, reverse bool) (dm.ListImageResult, error) CountImages(filters *ListImagesFilters) (int, error) GetImageById(id string) (*dm.Image, error) + GetImagesByIds(ids []string) ([]*dm.Image, error) DeleteImageById(id string) error } diff --git a/image/relationship_repo.go b/image/relationship_repo.go new file mode 100644 index 0000000..415b98f --- /dev/null +++ b/image/relationship_repo.go @@ -0,0 +1,316 @@ +//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) + GetParentsByImageIds(imageIds []string) (map[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") + } + + 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(parsedImageId), + UUID(parsedParentId), + 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) { + 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(parsed)), + ).ORDER_BY( + ImageParentTable.CreatedAt.ASC(), + ) + + var dest []model.ImageParentTable + if err = stmt.Query(repo.DB, &dest); err != nil { + return nil, err + } + return mapRelationships(dest), nil +} + +func (repo *DBImageRelationshipRepo) GetParentsByImageIds(imageIds []string) (map[string][]ImageRelationship, error) { + if len(imageIds) == 0 { + return nil, nil + } + uuids := make([]Expression, len(imageIds)) + for i, id := range imageIds { + 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, + ).FROM( + ImageParentTable, + ).WHERE( + ImageParentTable.ImageID.IN(uuids...), + ).ORDER_BY( + ImageParentTable.CreatedAt.ASC(), + ) + + var dest []model.ImageParentTable + err := stmt.Query(repo.DB, &dest) + if err != nil { + return nil, err + } + + result := make(map[string][]ImageRelationship, len(imageIds)) + for _, r := range mapRelationships(dest) { + result[r.ImageId] = append(result[r.ImageId], r) + } + return result, nil +} + +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(parsed)), + ).ORDER_BY( + ImageParentTable.CreatedAt.ASC(), + ) + + var dest []model.ImageParentTable + if err = stmt.Query(repo.DB, &dest); err != nil { + return nil, err + } + return mapRelationships(dest), nil +} + +func (repo *DBImageRelationshipRepo) HasRelationships(imageId string) (bool, error) { + 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( + ImageParentTable, + ).WHERE( + ImageParentTable.ImageID.EQ(id).OR(ImageParentTable.ParentImageID.EQ(id)), + ).LIMIT(1) + + var dest []model.ImageParentTable + if err = stmt.Query(repo.DB, &dest); 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/image/repo.go b/image/repo.go index bd84be8..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" @@ -70,6 +71,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, @@ -79,6 +81,66 @@ func (repo *DBImageRepo) GetImageById(id string) (*dm.Image, error) { }, nil } +func (repo *DBImageRepo) GetImagesByIds(ids []string) ([]*dm.Image, error) { + if len(ids) == 0 { + return nil, nil + } + uuids := make([]Expression, len(ids)) + for i, id := range ids { + 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). + FROM(ImageTable). + WHERE( + ImageTable.ID.IN(uuids...).AND(ImageTable.DeletedAt.IS_NULL()), + ) + + var dest []model.ImageTable + err := stmt.Query(repo.DB, &dest) + if err != nil { + return nil, err + } + + idToImage := make(map[string]*dm.Image, len(dest)) + for _, d := range dest { + var parentId string + if d.ParentID != nil { + parentId = d.ParentID.String() + } + var rootId string + if d.RootID != nil { + rootId = d.RootID.String() + } + img := &dm.Image{ + Id: d.ID.String(), + Identifier: d.Identifier, + CreatedAt: d.CreatedAt, + Name: d.Name, + ParentId: parentId, + RootId: rootId, + Changes: d.Changes, + UploaderIP: utils.SafeDeref(d.UploaderIP), + MIMEType: d.MimeType, + NominalWidth: d.NominalWidth, + NominalHeight: d.NominalHeight, + NominalByteSize: d.NominalByteSize, + CreatedById: utils.SafeDerefWithDefault(d.CreatedByID, ZeroUUID).String(), + } + idToImage[img.Id] = img + } + + result := make([]*dm.Image, len(ids)) + for i, id := range ids { + result[i] = idToImage[id] + } + return result, nil +} + func (repo *DBImageRepo) CreateImage(image *dm.Image) (*dm.Image, error) { var parentId *string var rootId *string @@ -103,11 +165,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 +192,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 +444,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, diff --git a/web_client/src/__generated__/gql.ts b/web_client/src/__generated__/gql.ts index ef68244..9bb2606 100644 --- a/web_client/src/__generated__/gql.ts +++ b/web_client/src/__generated__/gql.ts @@ -18,8 +18,11 @@ type Documents = { "\nmutation authenticate($email: String!, $password: String!) {\n authenticate(email: $email, password: $password) {\n viewer {\n id\n organizationUser {\n id\n user {\n id\n email\n name\n }\n }\n }\n }\n}\n": typeof types.AuthenticateDocument, "\nmutation sendResetPasswordEmail($input: SendResetPasswordEmailInput!) {\n sendResetPasswordEmail(input: $input) {\n success\n }\n}\n": typeof types.SendResetPasswordEmailDocument, "\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 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 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, "\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, @@ -36,8 +39,11 @@ const documents: Documents = { "\nmutation authenticate($email: String!, $password: String!) {\n authenticate(email: $email, password: $password) {\n viewer {\n id\n organizationUser {\n id\n user {\n id\n email\n name\n }\n }\n }\n }\n}\n": types.AuthenticateDocument, "\nmutation sendResetPasswordEmail($input: SendResetPasswordEmailInput!) {\n sendResetPasswordEmail(input: $input) {\n success\n }\n}\n": types.SendResetPasswordEmailDocument, "\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 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 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, "\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, @@ -83,11 +89,23 @@ export function gql(source: "\nmutation resetPassword($input: ResetPasswordInput /** * 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 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 documents)["\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"]; +export function gql(source: "\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 documents)["\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"]; /** * 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 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. + */ +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..92a70f6 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']; @@ -399,7 +441,7 @@ export type ImagesQueryQueryVariables = Exact<{ }>; -export type ImagesQueryQuery = { __typename?: 'Query', viewer: { __typename?: 'Viewer', id: string, images: { __typename?: 'ImagesResult', pageInfo: { __typename?: 'ImagePageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null, totalCount?: number | null, currentCount?: number | null }, edges: Array<{ __typename?: 'ImageEdge', cursor: string, node: { __typename?: 'Image', id: string, url: string, name: string, nominalWidth: number, nominalHeight: number, nominalByteSize: number, createdAt: any, storedImages: Array<{ __typename?: 'StoredImage', id: string }>, createdBy?: { __typename?: 'OrganizationUser', id: string, user: { __typename?: 'User', id: string, avatarUrl: string } } | null } }> } } }; +export type ImagesQueryQuery = { __typename?: 'Query', viewer: { __typename?: 'Viewer', id: string, images: { __typename?: 'ImagesResult', pageInfo: { __typename?: 'ImagePageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null, totalCount?: number | null, currentCount?: number | null }, edges: Array<{ __typename?: 'ImageEdge', cursor: string, node: { __typename?: 'Image', id: string, url: string, name: string, nominalWidth: number, nominalHeight: number, nominalByteSize: number, createdAt: any, storedImages: Array<{ __typename?: 'StoredImage', id: string }>, parent?: { __typename?: 'Image', id: string, name: string } | null, createdBy?: { __typename?: 'OrganizationUser', id: string, user: { __typename?: 'User', id: string, avatarUrl: string } } | null } }> } } }; export type DeleteImageMutationVariables = Exact<{ input: DeleteImageInput; @@ -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, 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']; +}>; + + +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; }>; @@ -504,8 +567,11 @@ export const CreateUserWithOrganizationDocument = {"kind":"Document","definition export const AuthenticateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"authenticate"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"password"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authenticate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"password"},"value":{"kind":"Variable","name":{"kind":"Name","value":"password"}}}],"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"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const SendResetPasswordEmailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"sendResetPasswordEmail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SendResetPasswordEmailInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sendResetPasswordEmail"},"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 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 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":"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; 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/data.tsx b/web_client/src/common/ImageGallery/data.tsx index 35da290..a487c06 100644 --- a/web_client/src/common/ImageGallery/data.tsx +++ b/web_client/src/common/ImageGallery/data.tsx @@ -44,6 +44,10 @@ const ImagesQueryDoc = gql(` storedImages { id } + parent { + id + name + } createdBy { id user { @@ -177,7 +181,6 @@ export function useDeleteImage(imageId: string) { }, }, refetchQueries: [ImagesQueryDoc], - errorPolicy: "all", }); return { execute, diff --git a/web_client/src/common/ImageGallery/menu.tsx b/web_client/src/common/ImageGallery/menu.tsx index 2a20281..317e54c 100644 --- a/web_client/src/common/ImageGallery/menu.tsx +++ b/web_client/src/common/ImageGallery/menu.tsx @@ -5,11 +5,15 @@ 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"; +import { useNavigate, type NavigateFunction } from "react-router"; enum ImageMenuItemName { + DETAILS = "details", + EDIT = "edit", DOWNLOAD = "download", COPY_URL = "copy-url", DELETE = "delete", @@ -23,7 +27,29 @@ 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", + names: [ImageMenuItemName.DELETE], + }, + ], +}; + +export const ADMIN_MENU_CONFIG: ImageItemMenuConfig = { + sections: [ + { + id: "actions", + names: [ + ImageMenuItemName.DETAILS, + ImageMenuItemName.DOWNLOAD, + ImageMenuItemName.COPY_URL, + ], }, { id: "delete", @@ -35,12 +61,15 @@ export const DEFAULT_MENU_CONFIG: ImageItemMenuConfig = { type MenuItemGetterProps = { image: RenderingImageItem; i18n: i18nType; + navigate: NavigateFunction; onDelete?: () => PromiseLike; }; 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 +82,34 @@ function getMenuItemByName( return MENU_ITEM_GETTERS[name](props); } +function getDetailsMenuItem({ + image: { id }, + i18n, + navigate, +}: MenuItemGetterProps): MenuItem { + return { + id: ImageMenuItemName.DETAILS, + children: i18n.t("imageItem.details", "Details"), + action: () => { + navigate(routes.profile.image(id)); + }, + }; +} + +function getEditMenuItem({ + image: { id }, + i18n, + navigate, +}: MenuItemGetterProps): MenuItem { + return { + id: ImageMenuItemName.EDIT, + children: i18n.t("imageItem.edit", "Edit"), + action: () => { + navigate(routes.profile.editImage(id)); + }, + }; +} + function getDownloadMenuItem({ image: { url }, i18n, @@ -98,9 +155,15 @@ function getDeleteMenuItem({ showCancel: true, }).then((confirmed) => { if (!confirmed) return; - onDelete?.().then(() => { - toast(i18n.t("common.toast.deleted")); - }); + onDelete?.().then( + () => { + toast(i18n.t("common.toast.deleted")); + }, + (err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + toast(message, { type: "error" }); + }, + ); }); }, }; @@ -110,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; @@ -119,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 d7772e0..b6b7f3b 100644 --- a/web_client/src/common/ImageGallery/render.tsx +++ b/web_client/src/common/ImageGallery/render.tsx @@ -16,6 +16,7 @@ import { DefaultMenuIcon } from "~src/ui/menu"; import { Link } from "react-router"; import { useAuth } from "~src/lib/auth"; import { routes } from "~src/routes"; +import { useTranslation } from "react-i18next"; type DumbImageGalleryProps = { images: RenderingImageItem[]; @@ -73,6 +74,7 @@ export function ImageItemRenderer({ showCreatorInfo?: boolean; }) { const menuSections = useImageItemMenu(image, image.menuConfig); + const { t } = useTranslation(); const { url, name, nominalWidth, nominalHeight, nominalByteSize, createdAt } = image; const { data: authData } = useAuth(); @@ -90,16 +92,14 @@ export function ImageItemRenderer({ /> ) : null; - console.log(showCreatorInfo, image); - return (
-
+
{menuSections && ( -
+
)} - {`preview + + {image.parent && ( + + {t("imageItem.revision")} + + )} + {`preview +
{avatarEl && image.createdBy && canLinkToUser ? ( diff --git a/web_client/src/common/ImageGallery/types.ts b/web_client/src/common/ImageGallery/types.ts index cd61497..d3abac7 100644 --- a/web_client/src/common/ImageGallery/types.ts +++ b/web_client/src/common/ImageGallery/types.ts @@ -8,6 +8,10 @@ export type RenderingImageItem = { nominalHeight: number; nominalByteSize: number; createdAt: string; + parent?: { + id: string; + name: string; + } | null; createdBy?: { id: string; user: { diff --git a/web_client/src/editor/EditorCanvas.tsx b/web_client/src/editor/EditorCanvas.tsx new file mode 100644 index 0000000..eb7003f --- /dev/null +++ b/web_client/src/editor/EditorCanvas.tsx @@ -0,0 +1,200 @@ +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(() => { + let cancelled = false; + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + if (!cancelled) { + baseImgRef.current = img; + setBaseLoaded(true); + } + }; + img.src = baseImageUrl; + return () => { + cancelled = true; + setBaseLoaded(false); + }; + }, [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 dpr = window.devicePixelRatio || 1; + const maxCssW = container.clientWidth; + const ratio = baseImg.naturalWidth / baseImg.naturalHeight; + // 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, bufferW, bufferH); + ctx.drawImage(baseImg, 0, 0, bufferW, bufferH); + + // Draw overlay + if (overlay.image) { + const shortSide = Math.min(bufferW, bufferH); + 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 * bufferW; + const py = overlay.y * bufferH; + + // 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..b2991bb --- /dev/null +++ b/web_client/src/editor/ImageDetail.tsx @@ -0,0 +1,293 @@ +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 + organizationUser { + id + } + image(id: $id) { + id + url + name + identifier + nominalWidth + nominalHeight + MIMEType + createdAt + changes + createdBy { + id + } + lineage { + id + url + name + changes + createdAt + } + } + } + } +`); + +export function ImageDetail() { + const { imageId } = useParams<{ imageId: string }>(); + const navigate = useNavigate(); + const { t } = useTranslation(); + const [fetchImage, { data, loading, error }] = useLazyQuery(ImageDetailDoc); + + React.useEffect(() => { + if (imageId) { + fetchImage({ variables: { id: imageId } }); + } + }, [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) + return ( +
+ {t("imageDetail.errorLoading")}: {error.message} +
+ ); + if (!image) + return ( +
+ {t("imageDetail.imageNotFound")} +
+ ); + + const lineage = image.lineage; + const currentIndex = lineage.findIndex((img) => img.id === image.id); + + return ( +
+
+

+ {image.name} +

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

+ {t("imageDetail.editHistory")} +

+
+ {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 + ? t("imageDetail.original") + : changeSet + ? changeSet + : t("imageDetail.editFallback")} + +
+
+ ); + })} +
+
+
+ )} +
+
+ ); +} + +function CopyURLs({ url, name }: { url: string; name: string }) { + const { t } = useTranslation(); + 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 ( +
+

+ {t("imageDetail.copyReferences")} +

+
+ {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..b687d4c --- /dev/null +++ b/web_client/src/editor/ImageEditor.tsx @@ -0,0 +1,209 @@ +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 { useTranslation } from "react-i18next"; +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 { t } = useTranslation(); + 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; + } + let cancelled = false; + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + if (!cancelled) setOverlayImg(img); + }; + img.src = absoluteURL(settings.overlayImageUrl); + return () => { + cancelled = true; + 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(t("imageEditor.watermarkApplied")); + navigate(routes.profile.image(newImage.id), { replace: true }); + } + } catch (_err) { + toast.error(t("imageEditor.watermarkFailed")); + } + }, [t, imageId, settings, applyWatermark, navigate]); + + if (loading) return ; + if (error) + return ( +
+ {t("imageEditor.errorLoading")}: {error.message} +
+ ); + if (!image) + return ( +
+ {t("imageEditor.imageNotFound")} +
+ ); + + return ( +
+
+

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

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

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

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

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

+ +
+
+
+
+ ); +} diff --git a/web_client/src/editor/WatermarkTool.tsx b/web_client/src/editor/WatermarkTool.tsx new file mode 100644 index 0000000..9b48ab6 --- /dev/null +++ b/web_client/src/editor/WatermarkTool.tsx @@ -0,0 +1,232 @@ +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"; +import { useTranslation } from "react-i18next"; + +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_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({ + baseImageId, + settings, + onSettingsChange, + onApply, + applying, +}: WatermarkToolProps) { + const { t } = useTranslation(); + 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_KEYS).map(([value, key]) => ( + + ))} + + + + + )} +
+ ); +} + +function OverlayPicker({ + userId, + itemRenderer, +}: { + userId: string; + itemRenderer: (image: RenderingImageItem) => React.ReactNode; +}) { + const { t } = useTranslation(); + 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" + /> +

+ {t("watermarkTool.selectHint")} +

+ +
+ ); +} 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/localization/en.json b/web_client/src/localization/en.json index 595c822..b9fca8c 100644 --- a/web_client/src/localization/en.json +++ b/web_client/src/localization/en.json @@ -5,7 +5,8 @@ "save": "Save", "cancel": "Cancel", "delete": "Delete", - "deleteWithConfirm": "Delete…" + "deleteWithConfirm": "Delete…", + "back": "Back" }, "toast": { "copied": "Copied", @@ -142,9 +143,49 @@ "viewImagesButton": "View Images" }, "imageItem": { + "details": "Details", + "edit": "Edit", "copyURL": "Copy URL", "download": "Download", - "confirmDelete": "Are you sure you want to delete this image?
{{name}}" + "confirmDelete": "Are you sure you want to delete this image?
{{name}}", + "revision": "Revision" + }, + "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": { diff --git a/web_client/src/localization/ko.json b/web_client/src/localization/ko.json index 182f644..137d319 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,9 +143,49 @@ "viewImagesButton": "이미지 보기" }, "imageItem": { + "details": "상세 정보", + "edit": "편집", "copyURL": "URL 복사", "download": "다운로드", - "confirmDelete": "이 이미지를 삭제하시겠습니까?
{{name}}" + "confirmDelete": "이 이미지를 삭제하시겠습니까?
{{name}}", + "revision": "수정본" + }, + "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": { diff --git a/web_client/src/localization/ru.json b/web_client/src/localization/ru.json index 5a92a6f..3f9a009 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,9 +143,49 @@ "viewImagesButton": "Просмотр изображений" }, "imageItem": { + "details": "Подробности", + "edit": "Изменить", "copyURL": "Копировать URL", "download": "Скачать", - "confirmDelete": "Вы уверены, что хотите удалить это изображение?
{{name}}" + "confirmDelete": "Вы уверены, что хотите удалить это изображение?
{{name}}", + "revision": "Ревизия" + }, + "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": { diff --git a/web_client/src/localization/th.json b/web_client/src/localization/th.json index 3354653..8c11560 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,9 +143,49 @@ "viewImagesButton": "ดูภาพ" }, "imageItem": { + "details": "รายละเอียด", + "edit": "แก้ไข", "copyURL": "คัดลอก URL", "download": "ดาวน์โหลด", - "confirmDelete": "คุณแน่ใจหรือไม่ว่าต้องการลบภาพนี้?
{{name}}" + "confirmDelete": "คุณแน่ใจหรือไม่ว่าต้องการลบภาพนี้?
{{name}}", + "revision": "ฉบับแก้ไข" + }, + "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": { diff --git a/web_client/src/localization/zh_hans.json b/web_client/src/localization/zh_hans.json index a4e2d98..452f9a6 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,9 +143,49 @@ "viewImagesButton": "查看图片" }, "imageItem": { + "details": "详情", + "edit": "编辑", "copyURL": "复制链接", "download": "下载", - "confirmDelete": "确定要删除这张图片吗?
{{name}}" + "confirmDelete": "确定要删除这张图片吗?
{{name}}", + "revision": "修订版" + }, + "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": { diff --git a/web_client/src/localization/zh_hant.json b/web_client/src/localization/zh_hant.json index 1264f52..2b0a95f 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,9 +143,49 @@ "viewImagesButton": "檢視圖片" }, "imageItem": { + "details": "詳細資訊", + "edit": "編輯", "copyURL": "複製連結", "download": "下載", - "confirmDelete": "確定要刪除這張圖片嗎?
{{name}}" + "confirmDelete": "確定要刪除這張圖片嗎?
{{name}}", + "revision": "修訂版" + }, + "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": { 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 ( + `/${routeSegments.profile}/${routeSegments.images}/${imageId}`, + editImage: (imageId: string) => + `/${routeSegments.profile}/${routeSegments.images}/${imageId}/edit`, }, }; diff --git a/web_client/src/site-admin/pages/images/listImages.tsx b/web_client/src/site-admin/pages/images/listImages.tsx index 998a377..789f67c 100644 --- a/web_client/src/site-admin/pages/images/listImages.tsx +++ b/web_client/src/site-admin/pages/images/listImages.tsx @@ -1,7 +1,7 @@ import { t } from "i18next"; import React from "react"; import classNames from "classnames"; -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 { useDebounce } from "~src/lib/hooks"; import { HEADING_2 } from "~src/ui/classNames"; @@ -25,7 +25,7 @@ export function ListImages() {
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() {
- +
); diff --git a/web_client/src/uploader/uploader.tsx b/web_client/src/uploader/uploader.tsx index 51d6748..eb37433 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,18 @@ export function Uplodaer() { React.useEffect(() => { window.document.body.focus(); }, []); + + // Navigate to detail page when authenticated user uploads a single image + const hasNavigated = React.useRef(false); + React.useEffect(() => { + if (hasNavigated.current) return; + if (!isAuthenticated || uploadingFiles.length !== 1) return; + const file = uploadingFiles[0]; + if (file.loaded && file.imageId) { + hasNavigated.current = true; + navigate(routes.profile.image(file.imageId), { replace: true }); + } + }, [isAuthenticated, uploadingFiles, navigate]); const handleDrop = React.useCallback( (e: React.DragEvent) => { e.preventDefault();