mirror of
https://github.com/stashapp/stash.git
synced 2025-12-07 17:02:38 +01:00
Improve studio/tag/performer filtering (#3619)
* Support excludes field * Refactor studio filter * Refactor tags filter * Support excludes in tags --------- Co-authored-by: Kermie <kermie@isinthe.house>
This commit is contained in:
parent
45e61b9228
commit
62b6457f4e
30 changed files with 1105 additions and 117 deletions
|
|
@ -518,6 +518,7 @@ input FloatCriterionInput {
|
||||||
input MultiCriterionInput {
|
input MultiCriterionInput {
|
||||||
value: [ID!]
|
value: [ID!]
|
||||||
modifier: CriterionModifier!
|
modifier: CriterionModifier!
|
||||||
|
excludes: [ID!]
|
||||||
}
|
}
|
||||||
|
|
||||||
input GenderCriterionInput {
|
input GenderCriterionInput {
|
||||||
|
|
@ -534,6 +535,7 @@ input HierarchicalMultiCriterionInput {
|
||||||
value: [ID!]
|
value: [ID!]
|
||||||
modifier: CriterionModifier!
|
modifier: CriterionModifier!
|
||||||
depth: Int
|
depth: Int
|
||||||
|
excludes: [ID!]
|
||||||
}
|
}
|
||||||
|
|
||||||
input DateCriterionInput {
|
input DateCriterionInput {
|
||||||
|
|
|
||||||
|
|
@ -132,11 +132,13 @@ type HierarchicalMultiCriterionInput struct {
|
||||||
Value []string `json:"value"`
|
Value []string `json:"value"`
|
||||||
Modifier CriterionModifier `json:"modifier"`
|
Modifier CriterionModifier `json:"modifier"`
|
||||||
Depth *int `json:"depth"`
|
Depth *int `json:"depth"`
|
||||||
|
Excludes []string `json:"excludes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MultiCriterionInput struct {
|
type MultiCriterionInput struct {
|
||||||
Value []string `json:"value"`
|
Value []string `json:"value"`
|
||||||
Modifier CriterionModifier `json:"modifier"`
|
Modifier CriterionModifier `json:"modifier"`
|
||||||
|
Excludes []string `json:"excludes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DateCriterionInput struct {
|
type DateCriterionInput struct {
|
||||||
|
|
|
||||||
|
|
@ -629,9 +629,12 @@ type joinedMultiCriterionHandlerBuilder struct {
|
||||||
addJoinTable func(f *filterBuilder)
|
addJoinTable func(f *filterBuilder)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *joinedMultiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionInput) criterionHandlerFunc {
|
func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInput) criterionHandlerFunc {
|
||||||
return func(ctx context.Context, f *filterBuilder) {
|
return func(ctx context.Context, f *filterBuilder) {
|
||||||
if criterion != nil {
|
if c != nil {
|
||||||
|
// make local copy so we can modify it
|
||||||
|
criterion := *c
|
||||||
|
|
||||||
joinAlias := m.joinAs
|
joinAlias := m.joinAs
|
||||||
if joinAlias == "" {
|
if joinAlias == "" {
|
||||||
joinAlias = m.joinTable
|
joinAlias = m.joinTable
|
||||||
|
|
@ -653,37 +656,68 @@ func (m *joinedMultiCriterionHandlerBuilder) handler(criterion *models.MultiCrit
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(criterion.Value) == 0 {
|
if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var args []interface{}
|
// combine excludes if excludes modifier is selected
|
||||||
for _, tagID := range criterion.Value {
|
if criterion.Modifier == models.CriterionModifierExcludes {
|
||||||
args = append(args, tagID)
|
criterion.Modifier = models.CriterionModifierIncludesAll
|
||||||
|
criterion.Excludes = append(criterion.Excludes, criterion.Value...)
|
||||||
|
criterion.Value = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
whereClause := ""
|
if len(criterion.Value) > 0 {
|
||||||
havingClause := ""
|
whereClause := ""
|
||||||
|
havingClause := ""
|
||||||
|
|
||||||
|
var args []interface{}
|
||||||
|
for _, tagID := range criterion.Value {
|
||||||
|
args = append(args, tagID)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch criterion.Modifier {
|
||||||
|
case models.CriterionModifierIncludes:
|
||||||
|
// includes any of the provided ids
|
||||||
|
m.addJoinTable(f)
|
||||||
|
whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value)))
|
||||||
|
case models.CriterionModifierEquals:
|
||||||
|
// includes only the provided ids
|
||||||
|
m.addJoinTable(f)
|
||||||
|
whereClause = utils.StrFormat("{joinAlias}.{foreignFK} IN {inBinding} AND (SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{
|
||||||
|
"joinAlias": joinAlias,
|
||||||
|
"foreignFK": m.foreignFK,
|
||||||
|
"inBinding": getInBinding(len(criterion.Value)),
|
||||||
|
"joinTable": m.joinTable,
|
||||||
|
"primaryFK": m.primaryFK,
|
||||||
|
"primaryTable": m.primaryTable,
|
||||||
|
})
|
||||||
|
havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value))
|
||||||
|
args = append(args, len(criterion.Value))
|
||||||
|
case models.CriterionModifierIncludesAll:
|
||||||
|
// includes all of the provided ids
|
||||||
|
m.addJoinTable(f)
|
||||||
|
whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value)))
|
||||||
|
havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value))
|
||||||
|
}
|
||||||
|
|
||||||
|
f.addWhere(whereClause, args...)
|
||||||
|
f.addHaving(havingClause)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(criterion.Excludes) > 0 {
|
||||||
|
var args []interface{}
|
||||||
|
for _, tagID := range criterion.Excludes {
|
||||||
|
args = append(args, tagID)
|
||||||
|
}
|
||||||
|
|
||||||
switch criterion.Modifier {
|
|
||||||
case models.CriterionModifierIncludes:
|
|
||||||
// includes any of the provided ids
|
|
||||||
m.addJoinTable(f)
|
|
||||||
whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value)))
|
|
||||||
case models.CriterionModifierIncludesAll:
|
|
||||||
// includes all of the provided ids
|
|
||||||
m.addJoinTable(f)
|
|
||||||
whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value)))
|
|
||||||
havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value))
|
|
||||||
case models.CriterionModifierExcludes:
|
|
||||||
// excludes all of the provided ids
|
// excludes all of the provided ids
|
||||||
// need to use actual join table name for this
|
// need to use actual join table name for this
|
||||||
// <primaryTable>.id NOT IN (select <joinTable>.<primaryFK> from <joinTable> where <joinTable>.<foreignFK> in <values>)
|
// <primaryTable>.id NOT IN (select <joinTable>.<primaryFK> from <joinTable> where <joinTable>.<foreignFK> in <values>)
|
||||||
whereClause = fmt.Sprintf("%[1]s.id NOT IN (SELECT %[3]s.%[2]s from %[3]s where %[3]s.%[4]s in %[5]s)", m.primaryTable, m.primaryFK, m.joinTable, m.foreignFK, getInBinding(len(criterion.Value)))
|
whereClause := fmt.Sprintf("%[1]s.id NOT IN (SELECT %[3]s.%[2]s from %[3]s where %[3]s.%[4]s in %[5]s)", m.primaryTable, m.primaryFK, m.joinTable, m.foreignFK, getInBinding(len(criterion.Excludes)))
|
||||||
}
|
|
||||||
|
|
||||||
f.addWhere(whereClause, args...)
|
f.addWhere(whereClause, args...)
|
||||||
f.addHaving(havingClause)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -890,7 +924,7 @@ WHERE id in {inBinding}
|
||||||
return valuesClause
|
return valuesClause
|
||||||
}
|
}
|
||||||
|
|
||||||
func addHierarchicalConditionClauses(f *filterBuilder, criterion *models.HierarchicalMultiCriterionInput, table, idColumn string) {
|
func addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) {
|
||||||
switch criterion.Modifier {
|
switch criterion.Modifier {
|
||||||
case models.CriterionModifierIncludes:
|
case models.CriterionModifierIncludes:
|
||||||
f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn))
|
f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn))
|
||||||
|
|
@ -902,9 +936,12 @@ func addHierarchicalConditionClauses(f *filterBuilder, criterion *models.Hierarc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *hierarchicalMultiCriterionHandlerBuilder) handler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||||
return func(ctx context.Context, f *filterBuilder) {
|
return func(ctx context.Context, f *filterBuilder) {
|
||||||
if criterion != nil {
|
if c != nil {
|
||||||
|
// make a copy so we don't modify the original
|
||||||
|
criterion := *c
|
||||||
|
|
||||||
if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {
|
if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {
|
||||||
var notClause string
|
var notClause string
|
||||||
if criterion.Modifier == models.CriterionModifierNotNull {
|
if criterion.Modifier == models.CriterionModifierNotNull {
|
||||||
|
|
@ -919,19 +956,32 @@ func (m *hierarchicalMultiCriterionHandlerBuilder) handler(criterion *models.Hie
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(criterion.Value) == 0 {
|
if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth)
|
// combine excludes if excludes modifier is selected
|
||||||
|
if criterion.Modifier == models.CriterionModifierExcludes {
|
||||||
|
criterion.Modifier = models.CriterionModifierIncludesAll
|
||||||
|
criterion.Excludes = append(criterion.Excludes, criterion.Value...)
|
||||||
|
criterion.Value = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(criterion.Value) > 0 {
|
||||||
|
valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth)
|
||||||
|
|
||||||
|
switch criterion.Modifier {
|
||||||
|
case models.CriterionModifierIncludes:
|
||||||
|
f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause))
|
||||||
|
case models.CriterionModifierIncludesAll:
|
||||||
|
f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause))
|
||||||
|
f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", m.primaryTable, m.foreignFK, len(criterion.Value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(criterion.Excludes) > 0 {
|
||||||
|
valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth)
|
||||||
|
|
||||||
switch criterion.Modifier {
|
|
||||||
case models.CriterionModifierIncludes:
|
|
||||||
f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause))
|
|
||||||
case models.CriterionModifierIncludesAll:
|
|
||||||
f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause))
|
|
||||||
f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", m.primaryTable, m.foreignFK, len(criterion.Value)))
|
|
||||||
case models.CriterionModifierExcludes:
|
|
||||||
f.addWhere(fmt.Sprintf("%s.%s NOT IN (SELECT column2 FROM (%s)) OR %[1]s.%[2]s IS NULL", m.primaryTable, m.foreignFK, valuesClause))
|
f.addWhere(fmt.Sprintf("%s.%s NOT IN (SELECT column2 FROM (%s)) OR %[1]s.%[2]s IS NULL", m.primaryTable, m.foreignFK, valuesClause))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -953,9 +1003,26 @@ type joinedHierarchicalMultiCriterionHandlerBuilder struct {
|
||||||
primaryFK string
|
primaryFK string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
func (m *joinedHierarchicalMultiCriterionHandlerBuilder) addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) {
|
||||||
|
if criterion.Modifier == models.CriterionModifierEquals {
|
||||||
|
// includes only the provided ids
|
||||||
|
f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn))
|
||||||
|
f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", table, idColumn, len(criterion.Value)))
|
||||||
|
f.addWhere(utils.StrFormat("(SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{
|
||||||
|
"joinTable": m.joinTable,
|
||||||
|
"primaryFK": m.primaryFK,
|
||||||
|
"primaryTable": m.primaryTable,
|
||||||
|
}), len(criterion.Value))
|
||||||
|
} else {
|
||||||
|
addHierarchicalConditionClauses(f, criterion, table, idColumn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||||
return func(ctx context.Context, f *filterBuilder) {
|
return func(ctx context.Context, f *filterBuilder) {
|
||||||
if criterion != nil {
|
if c != nil {
|
||||||
|
// make a copy so we don't modify the original
|
||||||
|
criterion := *c
|
||||||
joinAlias := m.joinAs
|
joinAlias := m.joinAs
|
||||||
|
|
||||||
if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {
|
if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {
|
||||||
|
|
@ -974,25 +1041,59 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(criterion *mode
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(criterion.Value) == 0 {
|
// combine excludes if excludes modifier is selected
|
||||||
|
if criterion.Modifier == models.CriterionModifierExcludes {
|
||||||
|
criterion.Modifier = models.CriterionModifierIncludesAll
|
||||||
|
criterion.Excludes = append(criterion.Excludes, criterion.Value...)
|
||||||
|
criterion.Value = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth)
|
if len(criterion.Value) > 0 {
|
||||||
|
valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth)
|
||||||
|
|
||||||
joinTable := utils.StrFormat(`(
|
joinTable := utils.StrFormat(`(
|
||||||
SELECT j.*, d.column1 AS root_id, d.column2 AS item_id FROM {joinTable} AS j
|
SELECT j.*, d.column1 AS root_id, d.column2 AS item_id FROM {joinTable} AS j
|
||||||
INNER JOIN ({valuesClause}) AS d ON j.{foreignFK} = d.column2
|
INNER JOIN ({valuesClause}) AS d ON j.{foreignFK} = d.column2
|
||||||
)
|
)
|
||||||
`, utils.StrFormatMap{
|
`, utils.StrFormatMap{
|
||||||
"joinTable": m.joinTable,
|
"joinTable": m.joinTable,
|
||||||
"foreignFK": m.foreignFK,
|
"foreignFK": m.foreignFK,
|
||||||
"valuesClause": valuesClause,
|
"valuesClause": valuesClause,
|
||||||
})
|
})
|
||||||
|
|
||||||
f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.id", joinAlias, m.primaryFK, m.primaryTable))
|
f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.id", joinAlias, m.primaryFK, m.primaryTable))
|
||||||
|
|
||||||
addHierarchicalConditionClauses(f, criterion, joinAlias, "root_id")
|
m.addHierarchicalConditionClauses(f, criterion, joinAlias, "root_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(criterion.Excludes) > 0 {
|
||||||
|
valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth)
|
||||||
|
|
||||||
|
joinTable := utils.StrFormat(`(
|
||||||
|
SELECT j2.*, e.column1 AS root_id, e.column2 AS item_id FROM {joinTable} AS j2
|
||||||
|
INNER JOIN ({valuesClause}) AS e ON j2.{foreignFK} = e.column2
|
||||||
|
)
|
||||||
|
`, utils.StrFormatMap{
|
||||||
|
"joinTable": m.joinTable,
|
||||||
|
"foreignFK": m.foreignFK,
|
||||||
|
"valuesClause": valuesClause,
|
||||||
|
})
|
||||||
|
|
||||||
|
joinAlias2 := joinAlias + "2"
|
||||||
|
|
||||||
|
f.addLeftJoin(joinTable, joinAlias2, fmt.Sprintf("%s.%s = %s.id", joinAlias2, m.primaryFK, m.primaryTable))
|
||||||
|
|
||||||
|
// modify for exclusion
|
||||||
|
criterionCopy := criterion
|
||||||
|
criterionCopy.Modifier = models.CriterionModifierExcludes
|
||||||
|
criterionCopy.Value = c.Excludes
|
||||||
|
|
||||||
|
m.addHierarchicalConditionClauses(f, criterionCopy, joinAlias2, "root_id")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1011,7 +1011,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id
|
||||||
|
|
||||||
f.addLeftJoin("performer_tags", "", "performer_tags.gallery_id = galleries.id")
|
f.addLeftJoin("performer_tags", "", "performer_tags.gallery_id = galleries.id")
|
||||||
|
|
||||||
addHierarchicalConditionClauses(f, tags, "performer_tags", "root_tag_id")
|
addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -989,7 +989,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id
|
||||||
|
|
||||||
f.addLeftJoin("performer_tags", "", "performer_tags.image_id = images.id")
|
f.addLeftJoin("performer_tags", "", "performer_tags.image_id = images.id")
|
||||||
|
|
||||||
addHierarchicalConditionClauses(f, tags, "performer_tags", "root_tag_id")
|
addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1404,7 +1404,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id
|
||||||
|
|
||||||
f.addLeftJoin("performer_tags", "", "performer_tags.scene_id = scenes.id")
|
f.addLeftJoin("performer_tags", "", "performer_tags.scene_id = scenes.id")
|
||||||
|
|
||||||
addHierarchicalConditionClauses(f, tags, "performer_tags", "root_tag_id")
|
addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = m.primary_tag_id
|
||||||
|
|
||||||
f.addLeftJoin("marker_tags", "", "marker_tags.scene_marker_id = scene_markers.id")
|
f.addLeftJoin("marker_tags", "", "marker_tags.scene_marker_id = scene_markers.id")
|
||||||
|
|
||||||
addHierarchicalConditionClauses(f, tags, "marker_tags", "root_tag_id")
|
addHierarchicalConditionClauses(f, *tags, "marker_tags", "root_tag_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -254,7 +254,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = st.tag_id
|
||||||
|
|
||||||
f.addLeftJoin("scene_tags", "", "scene_tags.scene_id = scene_markers.scene_id")
|
f.addLeftJoin("scene_tags", "", "scene_tags.scene_id = scene_markers.scene_id")
|
||||||
|
|
||||||
addHierarchicalConditionClauses(f, tags, "scene_tags", "root_tag_id")
|
addHierarchicalConditionClauses(f, *tags, "scene_tags", "root_tag_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -518,7 +518,7 @@ func tagParentsCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalMu
|
||||||
|
|
||||||
f.addLeftJoin("parents", "", "parents.item_id = tags.id")
|
f.addLeftJoin("parents", "", "parents.item_id = tags.id")
|
||||||
|
|
||||||
addHierarchicalConditionClauses(f, tags, "parents", "root_id")
|
addHierarchicalConditionClauses(f, *tags, "parents", "root_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -567,7 +567,7 @@ func tagChildrenCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalM
|
||||||
|
|
||||||
f.addLeftJoin("children", "", "children.item_id = tags.id")
|
f.addLeftJoin("children", "", "children.item_id = tags.id")
|
||||||
|
|
||||||
addHierarchicalConditionClauses(f, tags, "children", "root_id")
|
addHierarchicalConditionClauses(f, *tags, "children", "root_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,12 @@ import { RatingFilter } from "./Filters/RatingFilter";
|
||||||
import { BooleanFilter } from "./Filters/BooleanFilter";
|
import { BooleanFilter } from "./Filters/BooleanFilter";
|
||||||
import { OptionFilter, OptionListFilter } from "./Filters/OptionFilter";
|
import { OptionFilter, OptionListFilter } from "./Filters/OptionFilter";
|
||||||
import { PathFilter } from "./Filters/PathFilter";
|
import { PathFilter } from "./Filters/PathFilter";
|
||||||
|
import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
|
||||||
|
import PerformersFilter from "./Filters/PerformersFilter";
|
||||||
|
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
||||||
|
import StudiosFilter from "./Filters/StudiosFilter";
|
||||||
|
import { TagsCriterion } from "src/models/list-filter/criteria/tags";
|
||||||
|
import TagsFilter from "./Filters/TagsFilter";
|
||||||
import { PhashCriterion } from "src/models/list-filter/criteria/phash";
|
import { PhashCriterion } from "src/models/list-filter/criteria/phash";
|
||||||
import { PhashFilter } from "./Filters/PhashFilter";
|
import { PhashFilter } from "./Filters/PhashFilter";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
|
|
@ -110,6 +116,33 @@ const GenericCriterionEditor: React.FC<IGenericCriterionEditor> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (criterion instanceof PerformersCriterion) {
|
||||||
|
return (
|
||||||
|
<PerformersFilter
|
||||||
|
criterion={criterion}
|
||||||
|
setCriterion={(c) => setCriterion(c)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criterion instanceof StudiosCriterion) {
|
||||||
|
return (
|
||||||
|
<StudiosFilter
|
||||||
|
criterion={criterion}
|
||||||
|
setCriterion={(c) => setCriterion(c)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criterion instanceof TagsCriterion) {
|
||||||
|
return (
|
||||||
|
<TagsFilter
|
||||||
|
criterion={criterion}
|
||||||
|
setCriterion={(c) => setCriterion(c)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (criterion instanceof ILabeledIdCriterion) {
|
if (criterion instanceof ILabeledIdCriterion) {
|
||||||
return (
|
return (
|
||||||
<LabeledIdFilter
|
<LabeledIdFilter
|
||||||
|
|
|
||||||
44
ui/v2.5/src/components/List/Filters/PerformersFilter.tsx
Normal file
44
ui/v2.5/src/components/List/Filters/PerformersFilter.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import React from "react";
|
||||||
|
import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
|
||||||
|
import { useFindPerformersQuery } from "src/core/generated-graphql";
|
||||||
|
import { ObjectsFilter } from "./SelectableFilter";
|
||||||
|
|
||||||
|
interface IPerformersFilter {
|
||||||
|
criterion: PerformersCriterion;
|
||||||
|
setCriterion: (c: PerformersCriterion) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePerformerQuery(query: string) {
|
||||||
|
const results = useFindPerformersQuery({
|
||||||
|
variables: {
|
||||||
|
filter: {
|
||||||
|
q: query,
|
||||||
|
per_page: 200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
results.data?.findPerformers.performers.map((p) => {
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
label: p.name,
|
||||||
|
};
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PerformersFilter: React.FC<IPerformersFilter> = ({
|
||||||
|
criterion,
|
||||||
|
setCriterion,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<ObjectsFilter
|
||||||
|
criterion={criterion}
|
||||||
|
setCriterion={setCriterion}
|
||||||
|
queryHook={usePerformerQuery}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PerformersFilter;
|
||||||
342
ui/v2.5/src/components/List/Filters/SelectableFilter.tsx
Normal file
342
ui/v2.5/src/components/List/Filters/SelectableFilter.tsx
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Button, Form } from "react-bootstrap";
|
||||||
|
import { Icon } from "src/components/Shared/Icon";
|
||||||
|
import {
|
||||||
|
faCheckCircle,
|
||||||
|
faMinus,
|
||||||
|
faPlus,
|
||||||
|
faTimesCircle,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { faTimesCircle as faTimesCircleRegular } from "@fortawesome/free-regular-svg-icons";
|
||||||
|
import { ClearableInput } from "src/components/Shared/ClearableInput";
|
||||||
|
import {
|
||||||
|
IHierarchicalLabelValue,
|
||||||
|
ILabeledId,
|
||||||
|
ILabeledValueListValue,
|
||||||
|
} from "src/models/list-filter/types";
|
||||||
|
import { cloneDeep, debounce } from "lodash-es";
|
||||||
|
import {
|
||||||
|
Criterion,
|
||||||
|
IHierarchicalLabeledIdCriterion,
|
||||||
|
} from "src/models/list-filter/criteria/criterion";
|
||||||
|
import { defineMessages, MessageDescriptor, useIntl } from "react-intl";
|
||||||
|
import { CriterionModifier } from "src/core/generated-graphql";
|
||||||
|
import { keyboardClickHandler } from "src/utils/keyboard";
|
||||||
|
|
||||||
|
interface ISelectedItem {
|
||||||
|
item: ILabeledId;
|
||||||
|
excluded?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectedItem: React.FC<ISelectedItem> = ({
|
||||||
|
item,
|
||||||
|
excluded = false,
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
const iconClassName = excluded ? "exclude-icon" : "include-button";
|
||||||
|
const spanClassName = excluded
|
||||||
|
? "excluded-object-label"
|
||||||
|
: "selected-object-label";
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
const icon = useMemo(() => {
|
||||||
|
if (!hovered) {
|
||||||
|
return excluded ? faTimesCircle : faCheckCircle;
|
||||||
|
}
|
||||||
|
|
||||||
|
return faTimesCircleRegular;
|
||||||
|
}, [hovered, excluded]);
|
||||||
|
|
||||||
|
function onMouseOver() {
|
||||||
|
setHovered(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseOut() {
|
||||||
|
setHovered(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
onClick={() => onClick()}
|
||||||
|
onKeyDown={keyboardClickHandler(onClick)}
|
||||||
|
onMouseEnter={() => onMouseOver()}
|
||||||
|
onMouseLeave={() => onMouseOut()}
|
||||||
|
onFocus={() => onMouseOver()}
|
||||||
|
onBlur={() => onMouseOut()}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Icon className={`fa-fw ${iconClassName}`} icon={icon} />
|
||||||
|
<span className={spanClassName}>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<div></div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ISelectableFilter {
|
||||||
|
query: string;
|
||||||
|
setQuery: (query: string) => void;
|
||||||
|
single: boolean;
|
||||||
|
includeOnly: boolean;
|
||||||
|
queryResults: ILabeledId[];
|
||||||
|
selected: ILabeledId[];
|
||||||
|
excluded: ILabeledId[];
|
||||||
|
onSelect: (value: ILabeledId, include: boolean) => void;
|
||||||
|
onUnselect: (value: ILabeledId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectableFilter: React.FC<ISelectableFilter> = ({
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
single,
|
||||||
|
queryResults,
|
||||||
|
selected,
|
||||||
|
excluded,
|
||||||
|
includeOnly,
|
||||||
|
onSelect,
|
||||||
|
onUnselect,
|
||||||
|
}) => {
|
||||||
|
const [internalQuery, setInternalQuery] = useState(query);
|
||||||
|
|
||||||
|
const onInputChange = useMemo(() => {
|
||||||
|
return debounce((input: string) => {
|
||||||
|
setQuery(input);
|
||||||
|
}, 250);
|
||||||
|
}, [setQuery]);
|
||||||
|
|
||||||
|
function onInternalInputChange(input: string) {
|
||||||
|
setInternalQuery(input);
|
||||||
|
onInputChange(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
const objects = useMemo(() => {
|
||||||
|
return queryResults.filter(
|
||||||
|
(p) =>
|
||||||
|
selected.find((s) => s.id === p.id) === undefined &&
|
||||||
|
excluded.find((s) => s.id === p.id) === undefined
|
||||||
|
);
|
||||||
|
}, [queryResults, selected, excluded]);
|
||||||
|
|
||||||
|
const includingOnly = includeOnly || (selected.length > 0 && single);
|
||||||
|
const excludingOnly = excluded.length > 0 && single;
|
||||||
|
|
||||||
|
const includeIcon = <Icon className="fa-fw include-button" icon={faPlus} />;
|
||||||
|
const excludeIcon = <Icon className="fa-fw exclude-icon" icon={faMinus} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="selectable-filter">
|
||||||
|
<ClearableInput
|
||||||
|
value={internalQuery}
|
||||||
|
setValue={(v) => onInternalInputChange(v)}
|
||||||
|
/>
|
||||||
|
<ul>
|
||||||
|
{selected.map((p) => (
|
||||||
|
<li key={p.id} className="selected-object">
|
||||||
|
<SelectedItem item={p} onClick={() => onUnselect(p)} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{excluded.map((p) => (
|
||||||
|
<li key={p.id} className="excluded-object">
|
||||||
|
<SelectedItem item={p} excluded onClick={() => onUnselect(p)} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{objects.map((p) => (
|
||||||
|
<li key={p.id} className="unselected-object">
|
||||||
|
{/* if excluding only, clicking on an item also excludes it */}
|
||||||
|
<a
|
||||||
|
onClick={() => onSelect(p, !excludingOnly)}
|
||||||
|
onKeyDown={keyboardClickHandler(() =>
|
||||||
|
onSelect(p, !excludingOnly)
|
||||||
|
)}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{!excludingOnly ? includeIcon : excludeIcon}
|
||||||
|
<span>{p.label}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{/* TODO item count */}
|
||||||
|
{/* <span className="object-count">{p.id}</span> */}
|
||||||
|
{!includingOnly && !excludingOnly && (
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelect(p, false);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
className="minimal exclude-button"
|
||||||
|
>
|
||||||
|
<span className="exclude-button-text">exclude</span>
|
||||||
|
{excludeIcon}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IObjectsFilter<T extends Criterion<ILabeledValueListValue>> {
|
||||||
|
criterion: T;
|
||||||
|
single?: boolean;
|
||||||
|
setCriterion: (criterion: T) => void;
|
||||||
|
queryHook: (query: string) => ILabeledId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ObjectsFilter = <
|
||||||
|
T extends Criterion<ILabeledValueListValue | IHierarchicalLabelValue>
|
||||||
|
>(
|
||||||
|
props: IObjectsFilter<T>
|
||||||
|
) => {
|
||||||
|
const { criterion, setCriterion, queryHook, single = false } = props;
|
||||||
|
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const queryResults = queryHook(query);
|
||||||
|
|
||||||
|
function onSelect(value: ILabeledId, newInclude: boolean) {
|
||||||
|
let newCriterion: T = cloneDeep(criterion);
|
||||||
|
|
||||||
|
if (newInclude) {
|
||||||
|
newCriterion.value.items.push(value);
|
||||||
|
} else {
|
||||||
|
if (newCriterion.value.excluded) {
|
||||||
|
newCriterion.value.excluded.push(value);
|
||||||
|
} else {
|
||||||
|
newCriterion.value.excluded = [value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCriterion(newCriterion);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUnselect = useCallback(
|
||||||
|
(value: ILabeledId) => {
|
||||||
|
if (!criterion) return;
|
||||||
|
|
||||||
|
let newCriterion: T = cloneDeep(criterion);
|
||||||
|
|
||||||
|
newCriterion.value.items = criterion.value.items.filter(
|
||||||
|
(v) => v.id !== value.id
|
||||||
|
);
|
||||||
|
newCriterion.value.excluded = criterion.value.excluded.filter(
|
||||||
|
(v) => v.id !== value.id
|
||||||
|
);
|
||||||
|
|
||||||
|
setCriterion(newCriterion);
|
||||||
|
},
|
||||||
|
[criterion, setCriterion]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedSelected = useMemo(() => {
|
||||||
|
const ret = criterion.value.items.slice();
|
||||||
|
ret.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
return ret;
|
||||||
|
}, [criterion]);
|
||||||
|
|
||||||
|
const sortedExcluded = useMemo(() => {
|
||||||
|
if (!criterion.value.excluded) return [];
|
||||||
|
const ret = criterion.value.excluded.slice();
|
||||||
|
ret.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
return ret;
|
||||||
|
}, [criterion]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectableFilter
|
||||||
|
single={single}
|
||||||
|
includeOnly={criterion.modifier === CriterionModifier.Equals}
|
||||||
|
query={query}
|
||||||
|
setQuery={setQuery}
|
||||||
|
selected={sortedSelected}
|
||||||
|
queryResults={queryResults}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onUnselect={onUnselect}
|
||||||
|
excluded={sortedExcluded}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IHierarchicalObjectsFilter<T extends IHierarchicalLabeledIdCriterion>
|
||||||
|
extends IObjectsFilter<T> {}
|
||||||
|
|
||||||
|
export const HierarchicalObjectsFilter = <
|
||||||
|
T extends IHierarchicalLabeledIdCriterion
|
||||||
|
>(
|
||||||
|
props: IHierarchicalObjectsFilter<T>
|
||||||
|
) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { criterion, setCriterion } = props;
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
studio_depth: {
|
||||||
|
id: "studio_depth",
|
||||||
|
defaultMessage: "Levels (empty for all)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onDepthChanged(depth: number) {
|
||||||
|
let newCriterion: T = cloneDeep(criterion);
|
||||||
|
newCriterion.value.depth = depth;
|
||||||
|
setCriterion(newCriterion);
|
||||||
|
}
|
||||||
|
|
||||||
|
function criterionOptionTypeToIncludeID(): string {
|
||||||
|
if (criterion.criterionOption.type === "studios") {
|
||||||
|
return "include-sub-studios";
|
||||||
|
}
|
||||||
|
if (criterion.criterionOption.type === "childTags") {
|
||||||
|
return "include-parent-tags";
|
||||||
|
}
|
||||||
|
return "include-sub-tags";
|
||||||
|
}
|
||||||
|
|
||||||
|
function criterionOptionTypeToIncludeUIString(): MessageDescriptor {
|
||||||
|
const optionType =
|
||||||
|
criterion.criterionOption.type === "studios"
|
||||||
|
? "include_sub_studios"
|
||||||
|
: criterion.criterionOption.type === "childTags"
|
||||||
|
? "include_parent_tags"
|
||||||
|
: "include_sub_tags";
|
||||||
|
return {
|
||||||
|
id: optionType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Check
|
||||||
|
id={criterionOptionTypeToIncludeID()}
|
||||||
|
checked={criterion.value.depth !== 0}
|
||||||
|
label={intl.formatMessage(criterionOptionTypeToIncludeUIString())}
|
||||||
|
onChange={() => onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
{criterion.value.depth !== 0 && (
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Control
|
||||||
|
className="btn-secondary"
|
||||||
|
type="number"
|
||||||
|
placeholder={intl.formatMessage(messages.studio_depth)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1)
|
||||||
|
}
|
||||||
|
defaultValue={
|
||||||
|
criterion.value && criterion.value.depth !== -1
|
||||||
|
? criterion.value.depth
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
)}
|
||||||
|
<ObjectsFilter {...props} />
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
ui/v2.5/src/components/List/Filters/StudiosFilter.tsx
Normal file
44
ui/v2.5/src/components/List/Filters/StudiosFilter.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useFindStudiosQuery } from "src/core/generated-graphql";
|
||||||
|
import { HierarchicalObjectsFilter } from "./SelectableFilter";
|
||||||
|
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
||||||
|
|
||||||
|
interface IStudiosFilter {
|
||||||
|
criterion: StudiosCriterion;
|
||||||
|
setCriterion: (c: StudiosCriterion) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useStudioQuery(query: string) {
|
||||||
|
const results = useFindStudiosQuery({
|
||||||
|
variables: {
|
||||||
|
filter: {
|
||||||
|
q: query,
|
||||||
|
per_page: 200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
results.data?.findStudios.studios.map((p) => {
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
label: p.name,
|
||||||
|
};
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const StudiosFilter: React.FC<IStudiosFilter> = ({
|
||||||
|
criterion,
|
||||||
|
setCriterion,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<HierarchicalObjectsFilter
|
||||||
|
criterion={criterion}
|
||||||
|
setCriterion={setCriterion}
|
||||||
|
queryHook={useStudioQuery}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudiosFilter;
|
||||||
41
ui/v2.5/src/components/List/Filters/TagsFilter.tsx
Normal file
41
ui/v2.5/src/components/List/Filters/TagsFilter.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useFindTagsQuery } from "src/core/generated-graphql";
|
||||||
|
import { HierarchicalObjectsFilter } from "./SelectableFilter";
|
||||||
|
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
||||||
|
|
||||||
|
interface ITagsFilter {
|
||||||
|
criterion: StudiosCriterion;
|
||||||
|
setCriterion: (c: StudiosCriterion) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useStudioQuery(query: string) {
|
||||||
|
const results = useFindTagsQuery({
|
||||||
|
variables: {
|
||||||
|
filter: {
|
||||||
|
q: query,
|
||||||
|
per_page: 200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
results.data?.findTags.tags.map((p) => {
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
label: p.name,
|
||||||
|
};
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagsFilter: React.FC<ITagsFilter> = ({ criterion, setCriterion }) => {
|
||||||
|
return (
|
||||||
|
<HierarchicalObjectsFilter
|
||||||
|
criterion={criterion}
|
||||||
|
setCriterion={setCriterion}
|
||||||
|
queryHook={useStudioQuery}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TagsFilter;
|
||||||
|
|
@ -255,6 +255,107 @@ input[type="range"].zoom-slider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-visible-button {
|
||||||
|
padding-left: 0.3rem;
|
||||||
|
padding-right: 0.3rem;
|
||||||
|
|
||||||
|
&:focus:not(.active):not(:hover) {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&.active:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectable-filter ul {
|
||||||
|
list-style-type: none;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
// to prevent unnecessary vertical scrollbar
|
||||||
|
padding-bottom: 0.15rem;
|
||||||
|
padding-inline-start: 0;
|
||||||
|
|
||||||
|
.unselected-object {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-object,
|
||||||
|
.excluded-object,
|
||||||
|
.unselected-object {
|
||||||
|
cursor: pointer;
|
||||||
|
height: 2em;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
|
||||||
|
a {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
height: 2em;
|
||||||
|
justify-content: space-between;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
background-color: rgba(138, 155, 168, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-object-label,
|
||||||
|
.excluded-object-label {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.include-button {
|
||||||
|
color: $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-icon {
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-button {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
|
||||||
|
.exclude-button-text {
|
||||||
|
color: $danger;
|
||||||
|
display: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .exclude-button-text,
|
||||||
|
&:focus .exclude-button-text {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-count {
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-object:hover,
|
||||||
|
.selected-object a:focus-visible,
|
||||||
|
.excluded-object:hover,
|
||||||
|
.excluded-object a:focus-visible {
|
||||||
|
.include-button,
|
||||||
|
.exclude-icon {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tilted {
|
.tilted {
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
54
ui/v2.5/src/components/Shared/ClearableInput.tsx
Normal file
54
ui/v2.5/src/components/Shared/ClearableInput.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Button, FormControl } from "react-bootstrap";
|
||||||
|
import { faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { useIntl } from "react-intl";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
import useFocus from "src/utils/focus";
|
||||||
|
|
||||||
|
interface IClearableInput {
|
||||||
|
value: string;
|
||||||
|
setValue: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClearableInput: React.FC<IClearableInput> = ({
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const [queryRef, setQueryFocus] = useFocus();
|
||||||
|
const queryClearShowing = !!value;
|
||||||
|
|
||||||
|
function onChangeQuery(event: React.FormEvent<HTMLInputElement>) {
|
||||||
|
setValue(event.currentTarget.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClearQuery() {
|
||||||
|
setValue("");
|
||||||
|
setQueryFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="clearable-input-group">
|
||||||
|
<FormControl
|
||||||
|
ref={queryRef}
|
||||||
|
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
|
||||||
|
value={value}
|
||||||
|
onInput={onChangeQuery}
|
||||||
|
className="clearable-text-field"
|
||||||
|
/>
|
||||||
|
{queryClearShowing && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClearQuery}
|
||||||
|
title={intl.formatMessage({ id: "actions.clear" })}
|
||||||
|
className="clearable-text-field-clear"
|
||||||
|
>
|
||||||
|
<Icon icon={faTimes} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClearableInput;
|
||||||
|
|
@ -414,3 +414,30 @@ div.react-datepicker {
|
||||||
#date-picker-portal .react-datepicker-popper {
|
#date-picker-portal .react-datepicker-popper {
|
||||||
z-index: 1600;
|
z-index: 1600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clearable-input-group {
|
||||||
|
align-items: stretch;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearable-text-field,
|
||||||
|
.clearable-text-field:active,
|
||||||
|
.clearable-text-field:focus {
|
||||||
|
background-color: #394b59;
|
||||||
|
border: 0;
|
||||||
|
border-color: #394b59;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearable-text-field-clear {
|
||||||
|
background-color: #394b59;
|
||||||
|
color: #bfccd6;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0.375rem 0.75rem;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export const StudioPerformersPanel: React.FC<IStudioPerformersPanel> = ({
|
||||||
const studioCriterion = new StudiosCriterion();
|
const studioCriterion = new StudiosCriterion();
|
||||||
studioCriterion.value = {
|
studioCriterion.value = {
|
||||||
items: [{ id: studio.id!, label: studio.name || `Studio ${studio.id}` }],
|
items: [{ id: studio.id!, label: studio.name || `Studio ${studio.id}` }],
|
||||||
|
excluded: [],
|
||||||
depth: 0,
|
depth: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export const TagMarkersPanel: React.FC<ITagMarkersPanel> = ({
|
||||||
tagCriterion = new TagsCriterion(TagsCriterionOption);
|
tagCriterion = new TagsCriterion(TagsCriterionOption);
|
||||||
tagCriterion.value = {
|
tagCriterion.value = {
|
||||||
items: [tagValue],
|
items: [tagValue],
|
||||||
|
excluded: [],
|
||||||
depth: 0,
|
depth: 0,
|
||||||
};
|
};
|
||||||
filter.criteria.push(tagCriterion);
|
filter.criteria.push(tagCriterion);
|
||||||
|
|
|
||||||
|
|
@ -22,21 +22,21 @@ export const usePerformerFilterHook = (
|
||||||
) {
|
) {
|
||||||
// add the performer if not present
|
// add the performer if not present
|
||||||
if (
|
if (
|
||||||
!performerCriterion.value.find((p) => {
|
!performerCriterion.value.items.find((p) => {
|
||||||
return p.id === performer.id;
|
return p.id === performer.id;
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
performerCriterion.value.push(performerValue);
|
performerCriterion.value.items.push(performerValue);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// overwrite
|
// overwrite
|
||||||
performerCriterion.value = [performerValue];
|
performerCriterion.value.items = [performerValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
performerCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
performerCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
||||||
} else {
|
} else {
|
||||||
performerCriterion = new PerformersCriterion();
|
performerCriterion = new PerformersCriterion();
|
||||||
performerCriterion.value = [performerValue];
|
performerCriterion.value.items = [performerValue];
|
||||||
performerCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
performerCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
||||||
filter.criteria.push(performerCriterion);
|
filter.criteria.push(performerCriterion);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export const useStudioFilterHook = (studio: GQL.StudioDataFragment) => {
|
||||||
studioCriterion = new StudiosCriterion();
|
studioCriterion = new StudiosCriterion();
|
||||||
studioCriterion.value = {
|
studioCriterion.value = {
|
||||||
items: [studioValue],
|
items: [studioValue],
|
||||||
|
excluded: [],
|
||||||
depth: (config?.configuration?.ui as IUIConfig)?.showChildStudioContent
|
depth: (config?.configuration?.ui as IUIConfig)?.showChildStudioContent
|
||||||
? -1
|
? -1
|
||||||
: 0,
|
: 0,
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export const useTagFilterHook = (tag: GQL.TagDataFragment) => {
|
||||||
tagCriterion = new TagsCriterion(TagsCriterionOption);
|
tagCriterion = new TagsCriterion(TagsCriterionOption);
|
||||||
tagCriterion.value = {
|
tagCriterion.value = {
|
||||||
items: [tagValue],
|
items: [tagValue],
|
||||||
|
excluded: [],
|
||||||
depth: (config?.configuration?.ui as IUIConfig)?.showChildTagContent
|
depth: (config?.configuration?.ui as IUIConfig)?.showChildTagContent
|
||||||
? -1
|
? -1
|
||||||
: 0,
|
: 0,
|
||||||
|
|
|
||||||
|
|
@ -726,6 +726,7 @@
|
||||||
"equals": "is",
|
"equals": "is",
|
||||||
"excludes": "excludes",
|
"excludes": "excludes",
|
||||||
"format_string": "{criterion} {modifierString} {valueString}",
|
"format_string": "{criterion} {modifierString} {valueString}",
|
||||||
|
"format_string_excludes": "{criterion} {modifierString} {valueString} (excludes {excludedString})",
|
||||||
"greater_than": "is greater than",
|
"greater_than": "is greater than",
|
||||||
"includes": "includes",
|
"includes": "includes",
|
||||||
"includes_all": "includes all",
|
"includes_all": "includes all",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
IStashIDValue,
|
IStashIDValue,
|
||||||
IDateValue,
|
IDateValue,
|
||||||
ITimestampValue,
|
ITimestampValue,
|
||||||
|
ILabeledValueListValue,
|
||||||
IPhashDistanceValue,
|
IPhashDistanceValue,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
|
|
@ -31,6 +32,7 @@ export type CriterionValue =
|
||||||
| string[]
|
| string[]
|
||||||
| ILabeledId[]
|
| ILabeledId[]
|
||||||
| IHierarchicalLabelValue
|
| IHierarchicalLabelValue
|
||||||
|
| ILabeledValueListValue
|
||||||
| INumberValue
|
| INumberValue
|
||||||
| IStashIDValue
|
| IStashIDValue
|
||||||
| IDateValue
|
| IDateValue
|
||||||
|
|
@ -138,6 +140,10 @@ export abstract class Criterion<V extends CriterionValue> {
|
||||||
return JSON.stringify(encodedCriterion);
|
return JSON.stringify(encodedCriterion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setValueFromQueryString(v: V) {
|
||||||
|
this.value = v;
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
public apply(outputFilter: Record<string, any>) {
|
public apply(outputFilter: Record<string, any>) {
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
|
@ -531,11 +537,21 @@ export class ILabeledIdCriterion extends Criterion<ILabeledId[]> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabelValue> {
|
export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabelValue> {
|
||||||
protected toCriterionInput(): HierarchicalMultiCriterionInput {
|
constructor(type: CriterionOption) {
|
||||||
return {
|
const value: IHierarchicalLabelValue = {
|
||||||
value: (this.value.items ?? []).map((v) => v.id),
|
items: [],
|
||||||
modifier: this.modifier,
|
excluded: [],
|
||||||
depth: this.value.depth,
|
depth: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
super(type, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setValueFromQueryString(v: IHierarchicalLabelValue) {
|
||||||
|
this.value = {
|
||||||
|
items: v.items || [],
|
||||||
|
excluded: v.excluded || [],
|
||||||
|
depth: v.depth || 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -549,24 +565,62 @@ export class IHierarchicalLabeledIdCriterion extends Criterion<IHierarchicalLabe
|
||||||
return `${labels} (+${this.value.depth > 0 ? this.value.depth : "all"})`;
|
return `${labels} (+${this.value.depth > 0 ? this.value.depth : "all"})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected toCriterionInput(): HierarchicalMultiCriterionInput {
|
||||||
|
let excludes: string[] = [];
|
||||||
|
if (this.value.excluded) {
|
||||||
|
excludes = this.value.excluded.map((v) => v.id);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: this.value.items.map((v) => v.id),
|
||||||
|
excludes: excludes,
|
||||||
|
modifier: this.modifier,
|
||||||
|
depth: this.value.depth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public isValid(): boolean {
|
public isValid(): boolean {
|
||||||
if (
|
if (
|
||||||
this.modifier === CriterionModifier.IsNull ||
|
this.modifier === CriterionModifier.IsNull ||
|
||||||
this.modifier === CriterionModifier.NotNull
|
this.modifier === CriterionModifier.NotNull ||
|
||||||
|
this.modifier === CriterionModifier.Equals
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.value.items.length > 0;
|
return (
|
||||||
|
this.value.items.length > 0 ||
|
||||||
|
(this.value.excluded && this.value.excluded.length > 0)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(type: CriterionOption) {
|
public getLabel(intl: IntlShape): string {
|
||||||
const value: IHierarchicalLabelValue = {
|
const modifierString = Criterion.getModifierLabel(intl, this.modifier);
|
||||||
items: [],
|
let valueString = "";
|
||||||
depth: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
super(type, value);
|
if (
|
||||||
|
this.modifier !== CriterionModifier.IsNull &&
|
||||||
|
this.modifier !== CriterionModifier.NotNull
|
||||||
|
) {
|
||||||
|
valueString = this.value.items.map((v) => v.label).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = "criterion_modifier.format_string";
|
||||||
|
let excludedString = "";
|
||||||
|
|
||||||
|
if (this.value.excluded && this.value.excluded.length > 0) {
|
||||||
|
id = "criterion_modifier.format_string_excludes";
|
||||||
|
excludedString = this.value.excluded.map((v) => v.label).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return intl.formatMessage(
|
||||||
|
{ id },
|
||||||
|
{
|
||||||
|
criterion: intl.formatMessage({ id: this.criterionOption.messageID }),
|
||||||
|
modifierString,
|
||||||
|
valueString,
|
||||||
|
excludedString,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,104 @@
|
||||||
import { ILabeledIdCriterion, ILabeledIdCriterionOption } from "./criterion";
|
/* eslint @typescript-eslint/no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */
|
||||||
|
import { IntlShape } from "react-intl";
|
||||||
|
import {
|
||||||
|
CriterionModifier,
|
||||||
|
MultiCriterionInput,
|
||||||
|
} from "src/core/generated-graphql";
|
||||||
|
import { ILabeledId, ILabeledValueListValue } from "../types";
|
||||||
|
import { Criterion, CriterionOption } from "./criterion";
|
||||||
|
|
||||||
export const PerformersCriterionOption = new ILabeledIdCriterionOption(
|
const modifierOptions = [
|
||||||
"performers",
|
CriterionModifier.IncludesAll,
|
||||||
"performers",
|
CriterionModifier.Includes,
|
||||||
"performers",
|
CriterionModifier.Equals,
|
||||||
true
|
];
|
||||||
);
|
|
||||||
|
|
||||||
export class PerformersCriterion extends ILabeledIdCriterion {
|
const defaultModifier = CriterionModifier.IncludesAll;
|
||||||
|
|
||||||
|
export const PerformersCriterionOption = new CriterionOption({
|
||||||
|
messageID: "performers",
|
||||||
|
type: "performers",
|
||||||
|
parameterName: "performers",
|
||||||
|
modifierOptions,
|
||||||
|
defaultModifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
export class PerformersCriterion extends Criterion<ILabeledValueListValue> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(PerformersCriterionOption);
|
super(PerformersCriterionOption, { items: [], excluded: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
public setValueFromQueryString(v: ILabeledId[] | ILabeledValueListValue) {
|
||||||
|
// #3619 - the format of performer value was changed from an array
|
||||||
|
// to an object. Check for both formats.
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
this.value = { items: v, excluded: [] };
|
||||||
|
} else {
|
||||||
|
this.value = {
|
||||||
|
items: v.items || [],
|
||||||
|
excluded: v.excluded || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLabelValue(_intl: IntlShape): string {
|
||||||
|
return this.value.items.map((v) => v.label).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toCriterionInput(): MultiCriterionInput {
|
||||||
|
let excludes: string[] = [];
|
||||||
|
if (this.value.excluded) {
|
||||||
|
excludes = this.value.excluded.map((v) => v.id);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: this.value.items.map((v) => v.id),
|
||||||
|
excludes: excludes,
|
||||||
|
modifier: this.modifier,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public isValid(): boolean {
|
||||||
|
if (
|
||||||
|
this.modifier === CriterionModifier.IsNull ||
|
||||||
|
this.modifier === CriterionModifier.NotNull ||
|
||||||
|
this.modifier === CriterionModifier.Equals
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
this.value.items.length > 0 ||
|
||||||
|
(this.value.excluded && this.value.excluded.length > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLabel(intl: IntlShape): string {
|
||||||
|
const modifierString = Criterion.getModifierLabel(intl, this.modifier);
|
||||||
|
let valueString = "";
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.modifier !== CriterionModifier.IsNull &&
|
||||||
|
this.modifier !== CriterionModifier.NotNull
|
||||||
|
) {
|
||||||
|
valueString = this.value.items.map((v) => v.label).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = "criterion_modifier.format_string";
|
||||||
|
let excludedString = "";
|
||||||
|
|
||||||
|
if (this.value.excluded && this.value.excluded.length > 0) {
|
||||||
|
id = "criterion_modifier.format_string_excludes";
|
||||||
|
excludedString = this.value.excluded.map((v) => v.label).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return intl.formatMessage(
|
||||||
|
{ id },
|
||||||
|
{
|
||||||
|
criterion: intl.formatMessage({ id: this.criterionOption.messageID }),
|
||||||
|
modifierString,
|
||||||
|
valueString,
|
||||||
|
excludedString,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,22 @@
|
||||||
|
import { CriterionModifier } from "src/core/generated-graphql";
|
||||||
import {
|
import {
|
||||||
|
CriterionOption,
|
||||||
IHierarchicalLabeledIdCriterion,
|
IHierarchicalLabeledIdCriterion,
|
||||||
ILabeledIdCriterion,
|
ILabeledIdCriterion,
|
||||||
ILabeledIdCriterionOption,
|
ILabeledIdCriterionOption,
|
||||||
} from "./criterion";
|
} from "./criterion";
|
||||||
|
|
||||||
export const StudiosCriterionOption = new ILabeledIdCriterionOption(
|
const modifierOptions = [CriterionModifier.Includes];
|
||||||
"studios",
|
|
||||||
"studios",
|
const defaultModifier = CriterionModifier.Includes;
|
||||||
"studios",
|
|
||||||
false
|
export const StudiosCriterionOption = new CriterionOption({
|
||||||
);
|
messageID: "studios",
|
||||||
|
type: "studios",
|
||||||
|
parameterName: "studios",
|
||||||
|
modifierOptions,
|
||||||
|
defaultModifier,
|
||||||
|
});
|
||||||
|
|
||||||
export class StudiosCriterion extends IHierarchicalLabeledIdCriterion {
|
export class StudiosCriterion extends IHierarchicalLabeledIdCriterion {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,51 @@
|
||||||
import {
|
import { CriterionModifier } from "src/core/generated-graphql";
|
||||||
IHierarchicalLabeledIdCriterion,
|
import { CriterionType } from "../types";
|
||||||
ILabeledIdCriterionOption,
|
import { CriterionOption, IHierarchicalLabeledIdCriterion } from "./criterion";
|
||||||
} from "./criterion";
|
|
||||||
|
|
||||||
export class TagsCriterion extends IHierarchicalLabeledIdCriterion {}
|
export class TagsCriterion extends IHierarchicalLabeledIdCriterion {}
|
||||||
|
|
||||||
export const TagsCriterionOption = new ILabeledIdCriterionOption(
|
class tagsCriterionOption extends CriterionOption {
|
||||||
|
constructor(messageID: string, value: CriterionType, parameterName: string) {
|
||||||
|
const modifierOptions = [
|
||||||
|
CriterionModifier.Includes,
|
||||||
|
CriterionModifier.IncludesAll,
|
||||||
|
CriterionModifier.Equals,
|
||||||
|
];
|
||||||
|
|
||||||
|
let defaultModifier = CriterionModifier.IncludesAll;
|
||||||
|
|
||||||
|
super({
|
||||||
|
messageID,
|
||||||
|
type: value,
|
||||||
|
parameterName,
|
||||||
|
modifierOptions,
|
||||||
|
defaultModifier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TagsCriterionOption = new tagsCriterionOption(
|
||||||
"tags",
|
"tags",
|
||||||
"tags",
|
"tags",
|
||||||
"tags",
|
"tags"
|
||||||
true
|
|
||||||
);
|
);
|
||||||
export const SceneTagsCriterionOption = new ILabeledIdCriterionOption(
|
export const SceneTagsCriterionOption = new tagsCriterionOption(
|
||||||
"sceneTags",
|
"sceneTags",
|
||||||
"sceneTags",
|
"sceneTags",
|
||||||
"scene_tags",
|
"scene_tags"
|
||||||
true
|
|
||||||
);
|
);
|
||||||
export const PerformerTagsCriterionOption = new ILabeledIdCriterionOption(
|
export const PerformerTagsCriterionOption = new tagsCriterionOption(
|
||||||
"performerTags",
|
"performerTags",
|
||||||
"performerTags",
|
"performerTags",
|
||||||
"performer_tags",
|
"performer_tags"
|
||||||
true
|
|
||||||
);
|
);
|
||||||
export const ParentTagsCriterionOption = new ILabeledIdCriterionOption(
|
export const ParentTagsCriterionOption = new tagsCriterionOption(
|
||||||
"parent_tags",
|
"parent_tags",
|
||||||
"parentTags",
|
"parentTags",
|
||||||
"parents",
|
"parents"
|
||||||
true
|
|
||||||
);
|
);
|
||||||
export const ChildTagsCriterionOption = new ILabeledIdCriterionOption(
|
export const ChildTagsCriterionOption = new tagsCriterionOption(
|
||||||
"sub_tags",
|
"sub_tags",
|
||||||
"childTags",
|
"childTags",
|
||||||
"children",
|
"children"
|
||||||
true
|
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ export class ListFilterModel {
|
||||||
// it's possible that we have unsupported criteria. Just skip if so.
|
// it's possible that we have unsupported criteria. Just skip if so.
|
||||||
if (criterion) {
|
if (criterion) {
|
||||||
if (encodedCriterion.value !== undefined) {
|
if (encodedCriterion.value !== undefined) {
|
||||||
criterion.value = encodedCriterion.value;
|
criterion.setValueFromQueryString(encodedCriterion.value);
|
||||||
}
|
}
|
||||||
criterion.modifier = encodedCriterion.modifier;
|
criterion.modifier = encodedCriterion.modifier;
|
||||||
this.criteria.push(criterion);
|
this.criteria.push(criterion);
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,14 @@ export interface ILabeledValue {
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ILabeledValueListValue {
|
||||||
|
items: ILabeledId[];
|
||||||
|
excluded: ILabeledId[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface IHierarchicalLabelValue {
|
export interface IHierarchicalLabelValue {
|
||||||
items: ILabeledId[];
|
items: ILabeledId[];
|
||||||
|
excluded: ILabeledId[];
|
||||||
depth: number;
|
depth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
9
ui/v2.5/src/utils/keyboard.ts
Normal file
9
ui/v2.5/src/utils/keyboard.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export function keyboardClickHandler(onClick: () => void) {
|
||||||
|
function onKeyDown(e: React.KeyboardEvent<HTMLAnchorElement>) {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return onKeyDown;
|
||||||
|
}
|
||||||
|
|
@ -38,12 +38,12 @@ const makePerformerScenesUrl = (
|
||||||
if (!performer.id) return "#";
|
if (!performer.id) return "#";
|
||||||
const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined);
|
const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined);
|
||||||
const criterion = new PerformersCriterion();
|
const criterion = new PerformersCriterion();
|
||||||
criterion.value = [
|
criterion.value.items = [
|
||||||
{ id: performer.id, label: performer.name || `Performer ${performer.id}` },
|
{ id: performer.id, label: performer.name || `Performer ${performer.id}` },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (extraPerformer) {
|
if (extraPerformer) {
|
||||||
criterion.value.push(extraPerformer);
|
criterion.value.items.push(extraPerformer);
|
||||||
}
|
}
|
||||||
|
|
||||||
filter.criteria.push(criterion);
|
filter.criteria.push(criterion);
|
||||||
|
|
@ -59,12 +59,12 @@ const makePerformerImagesUrl = (
|
||||||
if (!performer.id) return "#";
|
if (!performer.id) return "#";
|
||||||
const filter = new ListFilterModel(GQL.FilterMode.Images, undefined);
|
const filter = new ListFilterModel(GQL.FilterMode.Images, undefined);
|
||||||
const criterion = new PerformersCriterion();
|
const criterion = new PerformersCriterion();
|
||||||
criterion.value = [
|
criterion.value.items = [
|
||||||
{ id: performer.id, label: performer.name || `Performer ${performer.id}` },
|
{ id: performer.id, label: performer.name || `Performer ${performer.id}` },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (extraPerformer) {
|
if (extraPerformer) {
|
||||||
criterion.value.push(extraPerformer);
|
criterion.value.items.push(extraPerformer);
|
||||||
}
|
}
|
||||||
|
|
||||||
filter.criteria.push(criterion);
|
filter.criteria.push(criterion);
|
||||||
|
|
@ -80,12 +80,12 @@ const makePerformerGalleriesUrl = (
|
||||||
if (!performer.id) return "#";
|
if (!performer.id) return "#";
|
||||||
const filter = new ListFilterModel(GQL.FilterMode.Galleries, undefined);
|
const filter = new ListFilterModel(GQL.FilterMode.Galleries, undefined);
|
||||||
const criterion = new PerformersCriterion();
|
const criterion = new PerformersCriterion();
|
||||||
criterion.value = [
|
criterion.value.items = [
|
||||||
{ id: performer.id, label: performer.name || `Performer ${performer.id}` },
|
{ id: performer.id, label: performer.name || `Performer ${performer.id}` },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (extraPerformer) {
|
if (extraPerformer) {
|
||||||
criterion.value.push(extraPerformer);
|
criterion.value.items.push(extraPerformer);
|
||||||
}
|
}
|
||||||
|
|
||||||
filter.criteria.push(criterion);
|
filter.criteria.push(criterion);
|
||||||
|
|
@ -101,12 +101,12 @@ const makePerformerMoviesUrl = (
|
||||||
if (!performer.id) return "#";
|
if (!performer.id) return "#";
|
||||||
const filter = new ListFilterModel(GQL.FilterMode.Movies, undefined);
|
const filter = new ListFilterModel(GQL.FilterMode.Movies, undefined);
|
||||||
const criterion = new PerformersCriterion();
|
const criterion = new PerformersCriterion();
|
||||||
criterion.value = [
|
criterion.value.items = [
|
||||||
{ id: performer.id, label: performer.name || `Performer ${performer.id}` },
|
{ id: performer.id, label: performer.name || `Performer ${performer.id}` },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (extraPerformer) {
|
if (extraPerformer) {
|
||||||
criterion.value.push(extraPerformer);
|
criterion.value.items.push(extraPerformer);
|
||||||
}
|
}
|
||||||
|
|
||||||
filter.criteria.push(criterion);
|
filter.criteria.push(criterion);
|
||||||
|
|
@ -131,6 +131,7 @@ const makeStudioScenesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
|
||||||
const criterion = new StudiosCriterion();
|
const criterion = new StudiosCriterion();
|
||||||
criterion.value = {
|
criterion.value = {
|
||||||
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
|
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
|
||||||
|
excluded: [],
|
||||||
depth: 0,
|
depth: 0,
|
||||||
};
|
};
|
||||||
filter.criteria.push(criterion);
|
filter.criteria.push(criterion);
|
||||||
|
|
@ -143,6 +144,7 @@ const makeStudioImagesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
|
||||||
const criterion = new StudiosCriterion();
|
const criterion = new StudiosCriterion();
|
||||||
criterion.value = {
|
criterion.value = {
|
||||||
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
|
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
|
||||||
|
excluded: [],
|
||||||
depth: 0,
|
depth: 0,
|
||||||
};
|
};
|
||||||
filter.criteria.push(criterion);
|
filter.criteria.push(criterion);
|
||||||
|
|
@ -155,6 +157,7 @@ const makeStudioGalleriesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
|
||||||
const criterion = new StudiosCriterion();
|
const criterion = new StudiosCriterion();
|
||||||
criterion.value = {
|
criterion.value = {
|
||||||
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
|
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
|
||||||
|
excluded: [],
|
||||||
depth: 0,
|
depth: 0,
|
||||||
};
|
};
|
||||||
filter.criteria.push(criterion);
|
filter.criteria.push(criterion);
|
||||||
|
|
@ -167,6 +170,7 @@ const makeStudioMoviesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
|
||||||
const criterion = new StudiosCriterion();
|
const criterion = new StudiosCriterion();
|
||||||
criterion.value = {
|
criterion.value = {
|
||||||
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
|
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
|
||||||
|
excluded: [],
|
||||||
depth: 0,
|
depth: 0,
|
||||||
};
|
};
|
||||||
filter.criteria.push(criterion);
|
filter.criteria.push(criterion);
|
||||||
|
|
@ -179,6 +183,7 @@ const makeStudioPerformersUrl = (studio: Partial<GQL.StudioDataFragment>) => {
|
||||||
const criterion = new StudiosCriterion();
|
const criterion = new StudiosCriterion();
|
||||||
criterion.value = {
|
criterion.value = {
|
||||||
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
|
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
|
||||||
|
excluded: [],
|
||||||
depth: 0,
|
depth: 0,
|
||||||
};
|
};
|
||||||
filter.criteria.push(criterion);
|
filter.criteria.push(criterion);
|
||||||
|
|
@ -218,6 +223,7 @@ const makeParentTagsUrl = (tag: Partial<GQL.TagDataFragment>) => {
|
||||||
label: tag.name || `Tag ${tag.id}`,
|
label: tag.name || `Tag ${tag.id}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
excluded: [],
|
||||||
depth: 0,
|
depth: 0,
|
||||||
};
|
};
|
||||||
filter.criteria.push(criterion);
|
filter.criteria.push(criterion);
|
||||||
|
|
@ -235,6 +241,7 @@ const makeChildTagsUrl = (tag: Partial<GQL.TagDataFragment>) => {
|
||||||
label: tag.name || `Tag ${tag.id}`,
|
label: tag.name || `Tag ${tag.id}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
excluded: [],
|
||||||
depth: 0,
|
depth: 0,
|
||||||
};
|
};
|
||||||
filter.criteria.push(criterion);
|
filter.criteria.push(criterion);
|
||||||
|
|
@ -247,6 +254,7 @@ const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => {
|
||||||
const criterion = new TagsCriterion(TagsCriterionOption);
|
const criterion = new TagsCriterion(TagsCriterionOption);
|
||||||
criterion.value = {
|
criterion.value = {
|
||||||
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],
|
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],
|
||||||
|
excluded: [],
|
||||||
depth: 0,
|
depth: 0,
|
||||||
};
|
};
|
||||||
filter.criteria.push(criterion);
|
filter.criteria.push(criterion);
|
||||||
|
|
@ -259,6 +267,7 @@ const makeTagPerformersUrl = (tag: Partial<GQL.TagDataFragment>) => {
|
||||||
const criterion = new TagsCriterion(TagsCriterionOption);
|
const criterion = new TagsCriterion(TagsCriterionOption);
|
||||||
criterion.value = {
|
criterion.value = {
|
||||||
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],
|
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],
|
||||||
|
excluded: [],
|
||||||
depth: 0,
|
depth: 0,
|
||||||
};
|
};
|
||||||
filter.criteria.push(criterion);
|
filter.criteria.push(criterion);
|
||||||
|
|
@ -271,6 +280,7 @@ const makeTagSceneMarkersUrl = (tag: Partial<GQL.TagDataFragment>) => {
|
||||||
const criterion = new TagsCriterion(TagsCriterionOption);
|
const criterion = new TagsCriterion(TagsCriterionOption);
|
||||||
criterion.value = {
|
criterion.value = {
|
||||||
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],
|
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],
|
||||||
|
excluded: [],
|
||||||
depth: 0,
|
depth: 0,
|
||||||
};
|
};
|
||||||
filter.criteria.push(criterion);
|
filter.criteria.push(criterion);
|
||||||
|
|
@ -283,6 +293,7 @@ const makeTagGalleriesUrl = (tag: Partial<GQL.TagDataFragment>) => {
|
||||||
const criterion = new TagsCriterion(TagsCriterionOption);
|
const criterion = new TagsCriterion(TagsCriterionOption);
|
||||||
criterion.value = {
|
criterion.value = {
|
||||||
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],
|
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],
|
||||||
|
excluded: [],
|
||||||
depth: 0,
|
depth: 0,
|
||||||
};
|
};
|
||||||
filter.criteria.push(criterion);
|
filter.criteria.push(criterion);
|
||||||
|
|
@ -295,6 +306,7 @@ const makeTagImagesUrl = (tag: Partial<GQL.TagDataFragment>) => {
|
||||||
const criterion = new TagsCriterion(TagsCriterionOption);
|
const criterion = new TagsCriterion(TagsCriterionOption);
|
||||||
criterion.value = {
|
criterion.value = {
|
||||||
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],
|
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],
|
||||||
|
excluded: [],
|
||||||
depth: 0,
|
depth: 0,
|
||||||
};
|
};
|
||||||
filter.criteria.push(criterion);
|
filter.criteria.push(criterion);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue