Add Details, Studio Code, and Photographer to Images (#4217)

* Add Details, Code, and Photographer to Images
* Add date and details to image card
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
bob123491234 2023-11-27 22:45:07 -06:00 committed by GitHub
parent d1018b4c5d
commit 413311711f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 244 additions and 81 deletions

View File

@ -1,8 +1,11 @@
fragment SlimImageData on Image {
id
title
code
date
urls
details
photographer
rating100
organized
o_counter

View File

@ -1,9 +1,12 @@
fragment ImageData on Image {
id
title
code
rating100
date
urls
details
photographer
organized
o_counter
created_at

View File

@ -443,6 +443,7 @@ input ImageFilterType {
NOT: ImageFilterType
title: StringCriterionInput
details: StringCriterionInput
" Filter by image id"
id: IntCriterionInput
@ -486,6 +487,10 @@ input ImageFilterType {
created_at: TimestampCriterionInput
"Filter by last update time"
updated_at: TimestampCriterionInput
"Filter by studio code"
code: StringCriterionInput
"Filter by photographer"
photographer: StringCriterionInput
}
enum CriterionModifier {

View File

@ -1,11 +1,14 @@
type Image {
id: ID!
title: String
code: String
# rating expressed as 1-100
rating100: Int
url: String @deprecated(reason: "Use urls")
urls: [String!]!
date: String
details: String
photographer: String
o_counter: Int
organized: Boolean!
created_at: Time!
@ -37,12 +40,15 @@ input ImageUpdateInput {
clientMutationId: String
id: ID!
title: String
code: String
# rating expressed as 1-100
rating100: Int
organized: Boolean
url: String @deprecated(reason: "Use urls")
urls: [String!]
date: String
details: String
photographer: String
studio_id: ID
performer_ids: [ID!]
@ -56,12 +62,15 @@ input BulkImageUpdateInput {
clientMutationId: String
ids: [ID!]
title: String
code: String
# rating expressed as 1-100
rating100: Int
organized: Boolean
url: String @deprecated(reason: "Use urls")
urls: BulkUpdateStrings
date: String
details: String
photographer: String
studio_id: ID
performer_ids: BulkUpdateIds

View File

@ -107,6 +107,9 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp
updatedImage := models.NewImagePartial()
updatedImage.Title = translator.optionalString(input.Title, "title")
updatedImage.Code = translator.optionalString(input.Code, "code")
updatedImage.Details = translator.optionalString(input.Details, "details")
updatedImage.Photographer = translator.optionalString(input.Photographer, "photographer")
updatedImage.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedImage.Organized = translator.optionalBool(input.Organized, "organized")
@ -203,6 +206,9 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU
updatedImage := models.NewImagePartial()
updatedImage.Title = translator.optionalString(input.Title, "title")
updatedImage.Code = translator.optionalString(input.Code, "code")
updatedImage.Details = translator.optionalString(input.Details, "details")
updatedImage.Photographer = translator.optionalString(input.Photographer, "photographer")
updatedImage.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedImage.Organized = translator.optionalBool(input.Organized, "organized")

View File

@ -13,10 +13,13 @@ import (
// of cover image.
func ToBasicJSON(image *models.Image) *jsonschema.Image {
newImageJSON := jsonschema.Image{
Title: image.Title,
URLs: image.URLs.List(),
CreatedAt: json.JSONTime{Time: image.CreatedAt},
UpdatedAt: json.JSONTime{Time: image.UpdatedAt},
Title: image.Title,
Code: image.Code,
URLs: image.URLs.List(),
Details: image.Details,
Photographer: image.Photographer,
CreatedAt: json.JSONTime{Time: image.CreatedAt},
UpdatedAt: json.JSONTime{Time: image.UpdatedAt},
}
if image.Rating != nil {

View File

@ -76,6 +76,15 @@ func (i *Importer) imageJSONToImage(imageJSON jsonschema.Image) models.Image {
if imageJSON.Title != "" {
newImage.Title = imageJSON.Title
}
if imageJSON.Code != "" {
newImage.Code = imageJSON.Code
}
if imageJSON.Details != "" {
newImage.Details = imageJSON.Details
}
if imageJSON.Photographer != "" {
newImage.Photographer = imageJSON.Photographer
}
if imageJSON.Rating != 0 {
newImage.Rating = &imageJSON.Rating
}

View File

@ -3,11 +3,14 @@ package models
import "context"
type ImageFilterType struct {
And *ImageFilterType `json:"AND"`
Or *ImageFilterType `json:"OR"`
Not *ImageFilterType `json:"NOT"`
ID *IntCriterionInput `json:"id"`
Title *StringCriterionInput `json:"title"`
And *ImageFilterType `json:"AND"`
Or *ImageFilterType `json:"OR"`
Not *ImageFilterType `json:"NOT"`
ID *IntCriterionInput `json:"id"`
Title *StringCriterionInput `json:"title"`
Code *StringCriterionInput `json:"code"`
Details *StringCriterionInput `json:"details"`
Photographer *StringCriterionInput `json:"photographer"`
// Filter by file checksum
Checksum *StringCriterionInput `json:"checksum"`
// Filter by path

View File

@ -11,22 +11,25 @@ import (
type Image struct {
Title string `json:"title,omitempty"`
Code string `json:"code,omitempty"`
Studio string `json:"studio,omitempty"`
Rating int `json:"rating,omitempty"`
// deprecated - for import only
URL string `json:"url,omitempty"`
URLs []string `json:"urls,omitempty"`
Date string `json:"date,omitempty"`
Organized bool `json:"organized,omitempty"`
OCounter int `json:"o_counter,omitempty"`
Galleries []GalleryRef `json:"galleries,omitempty"`
Performers []string `json:"performers,omitempty"`
Tags []string `json:"tags,omitempty"`
Files []string `json:"files,omitempty"`
CreatedAt json.JSONTime `json:"created_at,omitempty"`
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
URLs []string `json:"urls,omitempty"`
Date string `json:"date,omitempty"`
Details string `json:"details,omitempty"`
Photographer string `json:"photographer,omitempty"`
Organized bool `json:"organized,omitempty"`
OCounter int `json:"o_counter,omitempty"`
Galleries []GalleryRef `json:"galleries,omitempty"`
Performers []string `json:"performers,omitempty"`
Tags []string `json:"tags,omitempty"`
Files []string `json:"files,omitempty"`
CreatedAt json.JSONTime `json:"created_at,omitempty"`
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
}
func (s Image) Filename(basename string, hash string) string {

View File

@ -11,7 +11,10 @@ import (
type Image struct {
ID int `json:"id"`
Title string `json:"title"`
Title string `json:"title"`
Code string `json:"code"`
Details string `json:"details"`
Photographer string `json:"photographer"`
// Rating expressed in 1-100 scale
Rating *int `json:"rating"`
Organized bool `json:"organized"`
@ -46,15 +49,18 @@ func NewImage() Image {
type ImagePartial struct {
Title OptionalString
Code OptionalString
// Rating expressed in 1-100 scale
Rating OptionalInt
URLs *UpdateStrings
Date OptionalDate
Organized OptionalBool
OCounter OptionalInt
StudioID OptionalInt
CreatedAt OptionalTime
UpdatedAt OptionalTime
Rating OptionalInt
URLs *UpdateStrings
Date OptionalDate
Details OptionalString
Photographer OptionalString
Organized OptionalBool
OCounter OptionalInt
StudioID OptionalInt
CreatedAt OptionalTime
UpdatedAt OptionalTime
GalleryIDs *UpdateIDs
TagIDs *UpdateIDs

View File

@ -33,7 +33,7 @@ const (
dbConnTimeout = 30
)
var appSchemaVersion uint = 53
var appSchemaVersion uint = 54
//go:embed migrations/*.sql
var migrationsBox embed.FS

View File

@ -31,21 +31,27 @@ const (
type imageRow struct {
ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"`
Code zero.String `db:"code"`
// expressed as 1-100
Rating null.Int `db:"rating"`
Date NullDate `db:"date"`
Organized bool `db:"organized"`
OCounter int `db:"o_counter"`
StudioID null.Int `db:"studio_id,omitempty"`
CreatedAt Timestamp `db:"created_at"`
UpdatedAt Timestamp `db:"updated_at"`
Rating null.Int `db:"rating"`
Date NullDate `db:"date"`
Details zero.String `db:"details"`
Photographer zero.String `db:"photographer"`
Organized bool `db:"organized"`
OCounter int `db:"o_counter"`
StudioID null.Int `db:"studio_id,omitempty"`
CreatedAt Timestamp `db:"created_at"`
UpdatedAt Timestamp `db:"updated_at"`
}
func (r *imageRow) fromImage(i models.Image) {
r.ID = i.ID
r.Title = zero.StringFrom(i.Title)
r.Code = zero.StringFrom(i.Code)
r.Rating = intFromPtr(i.Rating)
r.Date = NullDateFromDatePtr(i.Date)
r.Details = zero.StringFrom(i.Details)
r.Photographer = zero.StringFrom(i.Photographer)
r.Organized = i.Organized
r.OCounter = i.OCounter
r.StudioID = intFromPtr(i.StudioID)
@ -63,13 +69,16 @@ type imageQueryRow struct {
func (r *imageQueryRow) resolve() *models.Image {
ret := &models.Image{
ID: r.ID,
Title: r.Title.String,
Rating: nullIntPtr(r.Rating),
Date: r.Date.DatePtr(),
Organized: r.Organized,
OCounter: r.OCounter,
StudioID: nullIntPtr(r.StudioID),
ID: r.ID,
Title: r.Title.String,
Code: r.Code.String,
Rating: nullIntPtr(r.Rating),
Date: r.Date.DatePtr(),
Details: r.Details.String,
Photographer: r.Photographer.String,
Organized: r.Organized,
OCounter: r.OCounter,
StudioID: nullIntPtr(r.StudioID),
PrimaryFileID: nullIntFileIDPtr(r.PrimaryFileID),
Checksum: r.PrimaryFileChecksum.String,
@ -91,8 +100,11 @@ type imageRowRecord struct {
func (r *imageRowRecord) fromPartial(i models.ImagePartial) {
r.setNullString("title", i.Title)
r.setNullString("code", i.Code)
r.setNullInt("rating", i.Rating)
r.setNullDate("date", i.Date)
r.setNullString("details", i.Details)
r.setNullString("photographer", i.Photographer)
r.setBool("organized", i.Organized)
r.setInt("o_counter", i.OCounter)
r.setNullInt("studio_id", i.StudioID)
@ -672,6 +684,9 @@ func (qb *ImageStore) makeFilter(ctx context.Context, imageFilter *models.ImageF
stringCriterionHandler(imageFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f)
}))
query.handleCriterion(ctx, stringCriterionHandler(imageFilter.Title, "images.title"))
query.handleCriterion(ctx, stringCriterionHandler(imageFilter.Code, "images.code"))
query.handleCriterion(ctx, stringCriterionHandler(imageFilter.Details, "images.details"))
query.handleCriterion(ctx, stringCriterionHandler(imageFilter.Photographer, "images.photographer"))
query.handleCriterion(ctx, pathCriterionHandler(imageFilter.Path, "folders.path", "files.basename", qb.addFoldersTable))
query.handleCriterion(ctx, imageFileCountCriterionHandler(qb, imageFilter.FileCount))

View File

@ -57,13 +57,16 @@ func loadImageRelationships(ctx context.Context, expected models.Image, actual *
func Test_imageQueryBuilder_Create(t *testing.T) {
var (
title = "title"
rating = 60
ocounter = 5
url = "url"
date, _ = models.ParseDate("2003-02-01")
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
title = "title"
code = "code"
rating = 60
details = "details"
photographer = "photographer"
ocounter = 5
url = "url"
date, _ = models.ParseDate("2003-02-01")
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
imageFile = makeFileWithID(fileIdxStartImageFiles)
)
@ -77,8 +80,11 @@ func Test_imageQueryBuilder_Create(t *testing.T) {
"full",
models.Image{
Title: title,
Code: code,
Rating: &rating,
Date: &date,
Details: details,
Photographer: photographer,
URLs: models.NewRelatedStrings([]string{url}),
Organized: true,
OCounter: ocounter,
@ -94,13 +100,16 @@ func Test_imageQueryBuilder_Create(t *testing.T) {
{
"with file",
models.Image{
Title: title,
Rating: &rating,
Date: &date,
URLs: models.NewRelatedStrings([]string{url}),
Organized: true,
OCounter: ocounter,
StudioID: &studioIDs[studioIdxWithImage],
Title: title,
Code: code,
Rating: &rating,
Date: &date,
Details: details,
Photographer: photographer,
URLs: models.NewRelatedStrings([]string{url}),
Organized: true,
OCounter: ocounter,
StudioID: &studioIDs[studioIdxWithImage],
Files: models.NewRelatedFiles([]models.File{
imageFile.(*models.ImageFile),
}),
@ -214,13 +223,16 @@ func makeImageFileWithID(i int) *models.ImageFile {
func Test_imageQueryBuilder_Update(t *testing.T) {
var (
title = "title"
rating = 60
url = "url"
date, _ = models.ParseDate("2003-02-01")
ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
title = "title"
code = "code"
rating = 60
url = "url"
details = "details"
photographer = "photographer"
date, _ = models.ParseDate("2003-02-01")
ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
)
tests := []struct {
@ -233,9 +245,12 @@ func Test_imageQueryBuilder_Update(t *testing.T) {
&models.Image{
ID: imageIDs[imageIdxWithGallery],
Title: title,
Code: code,
Rating: &rating,
URLs: models.NewRelatedStrings([]string{url}),
Date: &date,
Details: details,
Photographer: photographer,
Organized: true,
OCounter: ocounter,
StudioID: &studioIDs[studioIdxWithImage],
@ -382,6 +397,9 @@ func clearImagePartial() models.ImagePartial {
// leave mandatory fields
return models.ImagePartial{
Title: models.OptionalString{Set: true, Null: true},
Code: models.OptionalString{Set: true, Null: true},
Details: models.OptionalString{Set: true, Null: true},
Photographer: models.OptionalString{Set: true, Null: true},
Rating: models.OptionalInt{Set: true, Null: true},
URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet},
Date: models.OptionalDate{Set: true, Null: true},
@ -394,13 +412,16 @@ func clearImagePartial() models.ImagePartial {
func Test_imageQueryBuilder_UpdatePartial(t *testing.T) {
var (
title = "title"
rating = 60
url = "url"
date, _ = models.ParseDate("2003-02-01")
ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
title = "title"
code = "code"
details = "details"
photographer = "photographer"
rating = 60
url = "url"
date, _ = models.ParseDate("2003-02-01")
ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
)
tests := []struct {
@ -414,8 +435,11 @@ func Test_imageQueryBuilder_UpdatePartial(t *testing.T) {
"full",
imageIDs[imageIdx1WithGallery],
models.ImagePartial{
Title: models.NewOptionalString(title),
Rating: models.NewOptionalInt(rating),
Title: models.NewOptionalString(title),
Code: models.NewOptionalString(code),
Details: models.NewOptionalString(details),
Photographer: models.NewOptionalString(photographer),
Rating: models.NewOptionalInt(rating),
URLs: &models.UpdateStrings{
Values: []string{url},
Mode: models.RelationshipUpdateModeSet,
@ -440,14 +464,17 @@ func Test_imageQueryBuilder_UpdatePartial(t *testing.T) {
},
},
models.Image{
ID: imageIDs[imageIdx1WithGallery],
Title: title,
Rating: &rating,
URLs: models.NewRelatedStrings([]string{url}),
Date: &date,
Organized: true,
OCounter: ocounter,
StudioID: &studioIDs[studioIdxWithImage],
ID: imageIDs[imageIdx1WithGallery],
Title: title,
Code: code,
Details: details,
Photographer: photographer,
Rating: &rating,
URLs: models.NewRelatedStrings([]string{url}),
Date: &date,
Organized: true,
OCounter: ocounter,
StudioID: &studioIDs[studioIdxWithImage],
Files: models.NewRelatedFiles([]models.File{
makeImageFile(imageIdx1WithGallery),
}),

View File

@ -0,0 +1,3 @@
ALTER TABLE `images` ADD COLUMN `code` text;
ALTER TABLE `images` ADD COLUMN `photographer` text;
ALTER TABLE `images` ADD COLUMN `details` text;

View File

@ -16,6 +16,7 @@ import {
faTag,
} from "@fortawesome/free-solid-svg-icons";
import { objectTitle } from "src/core/files";
import { TruncatedText } from "../Shared/TruncatedText";
interface IImageCardProps {
image: GQL.SlimImageDataFragment;
@ -175,6 +176,16 @@ export const ImageCard: React.FC<IImageCardProps> = (
<RatingBanner rating={props.image.rating100} />
</>
}
details={
<div className="image-card__details">
<span className="image-card__date">{props.image.date}</span>
<TruncatedText
className="image-card__description"
text={props.image.details}
lineCount={3}
/>
</div>
}
popovers={maybeRenderPopoverButtonGroup()}
selected={props.selected}
selecting={props.selecting}

View File

@ -21,6 +21,18 @@ export const ImageDetailPanel: React.FC<IImageDetailProps> = (props) => {
[props.image]
);
function renderDetails() {
if (!props.image.details) return;
return (
<>
<h6>
<FormattedMessage id="details" />:{" "}
</h6>
<p className="pre">{props.image.details}</p>
</>
);
}
function renderTags() {
if (props.image.tags.length === 0) return;
const tags = props.image.tags.map((tag) => (
@ -135,6 +147,16 @@ export const ImageDetailPanel: React.FC<IImageDetailProps> = (props) => {
{TextUtils.formatDateTime(intl, props.image.updated_at)}{" "}
</h6>
}
{props.image.code && (
<h6>
<FormattedMessage id="scene_code" />: {props.image.code}{" "}
</h6>
)}
{props.image.photographer && (
<h6>
<FormattedMessage id="photographer" />: {props.image.photographer}{" "}
</h6>
)}
</div>
{props.image.studio && (
<div className="col-3 d-xl-none">
@ -150,6 +172,7 @@ export const ImageDetailPanel: React.FC<IImageDetailProps> = (props) => {
</div>
<div className="row">
<div className="col-12">
{renderDetails()}
{renderTags()}
{renderPerformers()}
</div>

View File

@ -48,8 +48,11 @@ export const ImageEditPanel: React.FC<IProps> = ({
const schema = yup.object({
title: yup.string().ensure(),
code: yup.string().ensure(),
urls: yupUniqueStringList("urls"),
date: yupDateString(intl),
details: yup.string().ensure(),
photographer: yup.string().ensure(),
rating100: yup.number().integer().nullable().defined(),
studio_id: yup.string().required().nullable(),
performer_ids: yup.array(yup.string().required()).defined(),
@ -58,8 +61,11 @@ export const ImageEditPanel: React.FC<IProps> = ({
const initialValues = {
title: image.title ?? "",
code: image.code ?? "",
urls: image?.urls ?? [],
date: image?.date ?? "",
details: image.details ?? "",
photographer: image.photographer ?? "",
rating100: image.rating100 ?? null,
studio_id: image.studio?.id ?? null,
performer_ids: (image.performers ?? []).map((p) => p.id),
@ -204,6 +210,22 @@ export const ImageEditPanel: React.FC<IProps> = ({
return renderField("tag_ids", title, control, fullWidthProps);
}
function renderDetailsField() {
const props = {
labelProps: {
column: true,
sm: 3,
lg: 12,
},
fieldProps: {
sm: 9,
lg: 12,
},
};
return renderInputField("details", "textarea", "details", props);
}
return (
<div id="image-edit-details">
<Prompt
@ -234,16 +256,21 @@ export const ImageEditPanel: React.FC<IProps> = ({
<Row className="form-container px-3">
<Col lg={7} xl={12}>
{renderInputField("title")}
{renderInputField("code", "text", "scene_code")}
{renderURLListField("urls", "validation.urls_must_be_unique")}
{renderDateField("date")}
{renderInputField("photographer")}
{renderRatingField("rating100", "rating")}
{renderStudioField()}
{renderPerformersField()}
{renderTagsField()}
</Col>
<Col lg={5} xl={12}>
{renderDetailsField()}
</Col>
</Row>
</Form>
</div>

View File

@ -131,3 +131,7 @@ $imageTabWidth: 450px;
margin-bottom: 0;
}
}
.col-form-label {
padding-right: 2px;
}

View File

@ -33,6 +33,9 @@ const sortByOptions = [
const displayModeOptions = [DisplayMode.Grid, DisplayMode.Wall];
const criterionOptions = [
createStringCriterionOption("title"),
createStringCriterionOption("code", "scene_code"),
createStringCriterionOption("details"),
createStringCriterionOption("photographer"),
createMandatoryStringCriterionOption("checksum", "media_info.checksum"),
PathCriterionOption,
OrganizedCriterionOption,