mirror of
https://github.com/stashapp/stash.git
synced 2026-06-10 21:42:03 -05:00
Compare commits
91 Commits
docs-capti
...
v0.29.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7716c4dd87 | ||
|
|
2925325e68 | ||
|
|
beee37bc38 | ||
|
|
9be0cc3210 | ||
|
|
f2a787a2ba | ||
|
|
6cace4ff88 | ||
|
|
fa2fd31ac7 | ||
|
|
1b2b4c5221 | ||
|
|
336fa3b70e | ||
|
|
299e1ac1f9 | ||
|
|
fb7bd89834 | ||
|
|
f04be76224 | ||
|
|
db79cf9bb1 | ||
|
|
90baa31ee3 | ||
|
|
9b8300e882 | ||
|
|
d70ff551d4 | ||
|
|
1dccecc39c | ||
|
|
648875995c | ||
|
|
96b5a9448c | ||
|
|
fda97e7f6c | ||
|
|
869cbd496b | ||
|
|
5049d6e5c9 | ||
|
|
98df51755e | ||
|
|
947a17355c | ||
|
|
71e4071871 | ||
|
|
a6778d7d22 | ||
|
|
415e88808f | ||
|
|
d0283fe330 | ||
|
|
c162c3843d | ||
|
|
cb6c53deb5 | ||
|
|
97ca5a28d3 | ||
|
|
cee68ab87b | ||
|
|
c6bf20dd77 | ||
|
|
914bbfc164 | ||
|
|
060daef0b7 | ||
|
|
de5a9129b3 | ||
|
|
13953c2fbd | ||
|
|
479ad49e81 | ||
|
|
ce4b86daf5 | ||
|
|
0c5285c949 | ||
|
|
fbba4f06a9 | ||
|
|
e1b3b33c24 | ||
|
|
eb816d2e4f | ||
|
|
05e2fb26be | ||
|
|
7b182ac04b | ||
|
|
2e8bc3536f | ||
|
|
6d76fe690b | ||
|
|
d3f6301101 | ||
|
|
72c9c436be | ||
|
|
2ed9e5332d | ||
|
|
c5bad48ece | ||
|
|
af76f4a24a | ||
|
|
15bf28d5be | ||
|
|
c9ca40152f | ||
|
|
724d438721 | ||
|
|
acddf97771 | ||
|
|
823ed346c1 | ||
|
|
3bb771a149 | ||
|
|
793a5f826e | ||
|
|
8012f2eb8a | ||
|
|
98716d5568 | ||
|
|
edcc4e8968 | ||
|
|
12c4e1f61c | ||
|
|
cc97e96eaa | ||
|
|
b1883f3df5 | ||
|
|
fd36c0fac7 | ||
|
|
b5b207c940 | ||
|
|
c0ba119ebf | ||
|
|
e23bdfa204 | ||
|
|
14be3c24ff | ||
|
|
21ee88b149 | ||
|
|
dd9a1f5ce3 | ||
|
|
694675470e | ||
|
|
642b0f2291 | ||
|
|
1b3a8acab2 | ||
|
|
108c0c7de5 | ||
|
|
dcfb3b7d37 | ||
|
|
d98e9c6618 | ||
|
|
f01f95ddfb | ||
|
|
3a232b1d6c | ||
|
|
6f4920cb81 | ||
|
|
61ab6ce6bd | ||
|
|
7215b6e918 | ||
|
|
bd8ec8cb83 | ||
|
|
429022a468 | ||
|
|
5323d68d3d | ||
|
|
3af472d3b2 | ||
|
|
7eff7f02d0 | ||
|
|
661d2f64bb | ||
|
|
d0a7b09bf3 | ||
|
|
27bc6c8fca |
40
.github/ISSUE_TEMPLATE/bug_report.md
vendored
40
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[Bug Report] Short Form Subject (50 Chars or less)"
|
||||
labels: bug report
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem please ensure that your screenshots are SFW or at least appropriately censored.
|
||||
|
||||
**Stash Version: (from Settings -> About):**
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
64
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
64
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Bug Report
|
||||
description: Create a report to help us fix the bug
|
||||
labels: ["bug report"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: Provide a clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Detail the steps that would replicate this issue.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behaviour
|
||||
description: Provide clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Screenshots or additional context
|
||||
description: Provide any additional context and SFW screenshots here to help us solve this issue.
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: stashversion
|
||||
attributes:
|
||||
label: Stash version
|
||||
description: This can be found in Settings > About.
|
||||
placeholder: (e.g. v0.28.1)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: devicedetails
|
||||
attributes:
|
||||
label: Device details
|
||||
description: |
|
||||
If this is an issue that occurs when using the Stash interface, please provide details of the device/browser used which presents the reported issue.
|
||||
placeholder: (e.g. Firefox 97 (64-bit) on Windows 11)
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output from Settings > Logs. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Community forum
|
||||
url: https://discourse.stashapp.cc
|
||||
about: Start a discussion on the community forum.
|
||||
- name: Community Discord
|
||||
url: https://discord.gg/Y8MNsvQBvZ
|
||||
about: Chat with the community on Discord.
|
||||
- name: Documentation
|
||||
url: https://docs.stashapp.cc
|
||||
about: Check out documentation for help and information.
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: Discussion / Request for Commentary [RFC]
|
||||
about: This is for issues that will be discussed and won't necessarily result directly
|
||||
in commits or pull requests.
|
||||
title: "[RFC] Short Form Title"
|
||||
labels: help wanted
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- Update or delete the title if you need to delegate your title gore to something
|
||||
# Title
|
||||
|
||||
*### Scope*
|
||||
<!-- describe the scope of your topic and your goals ideally within a single paragraph or TL;DR kind of summary so its easier for people to determine if they can contribute at a glance. -->
|
||||
|
||||
## Long Form
|
||||
<!-- Only required if your scope and titles can't cover everything. -->
|
||||
|
||||
## Examples
|
||||
<!-- if you can show a picture or video examples post them here, please ensure that you respect people's time and attention and understand that people are volunteering their time, so concision is ideal and considerate. -->
|
||||
|
||||
## Reference Reading
|
||||
<!-- if there is any reference reading or documentation, please refer to it here. -->
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[Feature] Short Form Title (50 chars or less.)"
|
||||
labels: feature request
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
44
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
44
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Feature Request
|
||||
description: Request a new feature or idea to be added to Stash
|
||||
labels: ["feature request"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the feature you'd like
|
||||
description: Provide a clear description of the feature you'd like implemented
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: benefits
|
||||
attributes:
|
||||
label: Describe the benefits this would bring to existing users
|
||||
description: |
|
||||
Explain the measurable benefits this feature would achieve for existing users.
|
||||
The benefits should be described in terms of outcomes for users, not specific implementations.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: already_possible
|
||||
attributes:
|
||||
label: Is there an existing way to achieve this goal?
|
||||
description: |
|
||||
Yes/No. If Yes, describe how your proposed feature differs from or improves upon the current method
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: confirm-search
|
||||
attributes:
|
||||
label: Have you searched for an existing open/closed issue?
|
||||
description: |
|
||||
To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/stashapp/stash/issues?q=is%3Aissue) for any existing issues that cover the core request or benefit of your proposal.
|
||||
options:
|
||||
- label: I have searched for existing issues and none cover the core request of my proposal
|
||||
required: true
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
@@ -12,9 +12,8 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \
|
||||
FROM --platform=$TARGETPLATFORM alpine:latest AS app
|
||||
COPY --from=binary /stash /usr/bin/
|
||||
|
||||
RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg ruby tzdata vips vips-tools \
|
||||
&& pip install --user --break-system-packages mechanicalsoup cloudscraper stashapp-tools \
|
||||
&& gem install faraday
|
||||
RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg tzdata vips vips-tools \
|
||||
&& pip install --break-system-packages mechanicalsoup cloudscraper stashapp-tools
|
||||
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
|
||||
|
||||
# Basic build-time metadata as defined at https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys
|
||||
|
||||
@@ -16,6 +16,16 @@ type Query {
|
||||
ids: [ID!]
|
||||
): FindFilesResultType!
|
||||
|
||||
"Find a file by its id or path"
|
||||
findFolder(id: ID, path: String): Folder!
|
||||
|
||||
"Queries for Files"
|
||||
findFolders(
|
||||
folder_filter: FolderFilterType
|
||||
filter: FindFilterType
|
||||
ids: [ID!]
|
||||
): FindFoldersResultType!
|
||||
|
||||
"Find a scene by ID or Checksum"
|
||||
findScene(id: ID, checksum: String): Scene
|
||||
findSceneByHash(input: SceneHashInput!): Scene
|
||||
|
||||
@@ -10,7 +10,7 @@ type Folder {
|
||||
parent_folder_id: ID @deprecated(reason: "Use parent_folder instead")
|
||||
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
|
||||
|
||||
parent_folder: Folder!
|
||||
parent_folder: Folder
|
||||
zip_file: BasicFile
|
||||
|
||||
mod_time: Time!
|
||||
@@ -176,3 +176,8 @@ type FindFilesResultType {
|
||||
|
||||
files: [BaseFile!]!
|
||||
}
|
||||
|
||||
type FindFoldersResultType {
|
||||
count: Int!
|
||||
folders: [Folder!]!
|
||||
}
|
||||
|
||||
@@ -691,6 +691,7 @@ input FileFilterType {
|
||||
dir: StringCriterionInput
|
||||
|
||||
parent_folder: HierarchicalMultiCriterionInput
|
||||
zip_file: MultiCriterionInput
|
||||
|
||||
"Filter by modification time"
|
||||
mod_time: TimestampCriterionInput
|
||||
@@ -721,6 +722,32 @@ input FileFilterType {
|
||||
updated_at: TimestampCriterionInput
|
||||
}
|
||||
|
||||
input FolderFilterType {
|
||||
AND: FolderFilterType
|
||||
OR: FolderFilterType
|
||||
NOT: FolderFilterType
|
||||
|
||||
path: StringCriterionInput
|
||||
|
||||
parent_folder: HierarchicalMultiCriterionInput
|
||||
zip_file: MultiCriterionInput
|
||||
|
||||
"Filter by modification time"
|
||||
mod_time: TimestampCriterionInput
|
||||
|
||||
gallery_count: IntCriterionInput
|
||||
|
||||
"Filter by files that meet this criteria"
|
||||
files_filter: FileFilterType
|
||||
"Filter by related galleries that meet this criteria"
|
||||
galleries_filter: GalleryFilterType
|
||||
|
||||
"Filter by creation time"
|
||||
created_at: TimestampCriterionInput
|
||||
"Filter by last update time"
|
||||
updated_at: TimestampCriterionInput
|
||||
}
|
||||
|
||||
input VideoFileFilterInput {
|
||||
resolution: ResolutionCriterionInput
|
||||
orientation: OrientationCriterionInput
|
||||
|
||||
100
internal/api/resolver_query_find_folder.go
Normal file
100
internal/api/resolver_query_find_folder.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindFolder(ctx context.Context, id *string, path *string) (*models.Folder, error) {
|
||||
var ret *models.Folder
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Folder
|
||||
var err error
|
||||
switch {
|
||||
case id != nil:
|
||||
idInt, err := strconv.Atoi(*id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ret, err = qb.Find(ctx, models.FolderID(idInt))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case path != nil:
|
||||
ret, err = qb.FindByPath(ctx, *path)
|
||||
if err == nil && ret == nil {
|
||||
return errors.New("folder not found")
|
||||
}
|
||||
default:
|
||||
return errors.New("either id or path must be provided")
|
||||
}
|
||||
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindFolders(
|
||||
ctx context.Context,
|
||||
folderFilter *models.FolderFilterType,
|
||||
filter *models.FindFilterType,
|
||||
ids []string,
|
||||
) (ret *FindFoldersResultType, err error) {
|
||||
var folderIDs []models.FolderID
|
||||
if len(ids) > 0 {
|
||||
folderIDsInt, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
folderIDs = models.FolderIDsFromInts(folderIDsInt)
|
||||
}
|
||||
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
var folders []*models.Folder
|
||||
var err error
|
||||
|
||||
fields := collectQueryFields(ctx)
|
||||
result := &models.FolderQueryResult{}
|
||||
|
||||
if len(folderIDs) > 0 {
|
||||
folders, err = r.repository.Folder.FindMany(ctx, folderIDs)
|
||||
if err == nil {
|
||||
result.Count = len(folders)
|
||||
}
|
||||
} else {
|
||||
result, err = r.repository.Folder.Query(ctx, models.FolderQueryOptions{
|
||||
QueryOptions: models.QueryOptions{
|
||||
FindFilter: filter,
|
||||
Count: fields.Has("count"),
|
||||
},
|
||||
FolderFilter: folderFilter,
|
||||
})
|
||||
if err == nil {
|
||||
folders, err = result.Resolve(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ret = &FindFoldersResultType{
|
||||
Count: result.Count,
|
||||
Folders: folders,
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.So
|
||||
}
|
||||
|
||||
// TODO - this should happen after any scene is scraped
|
||||
if err := r.matchScenesRelationships(ctx, ret, *source.StashBoxEndpoint); err != nil {
|
||||
if err := r.matchScenesRelationships(ctx, ret, b.Endpoint); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
@@ -245,7 +245,7 @@ func (r *queryResolver) ScrapeMultiScenes(ctx context.Context, source scraper.So
|
||||
// just flatten the slice and pass it in
|
||||
flat := sliceutil.Flatten(ret)
|
||||
|
||||
if err := r.matchScenesRelationships(ctx, flat, *source.StashBoxEndpoint); err != nil {
|
||||
if err := r.matchScenesRelationships(ctx, flat, b.Endpoint); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S
|
||||
if len(ret) > 0 {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
for _, studio := range ret {
|
||||
if err := match.ScrapedStudioHierarchy(ctx, r.repository.Studio, studio, *source.StashBoxEndpoint); err != nil {
|
||||
if err := match.ScrapedStudioHierarchy(ctx, r.repository.Studio, studio, b.Endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,13 @@ func marshalScrapedGroups(content []scraper.ScrapedContent) ([]*models.ScrapedGr
|
||||
ret = append(ret, m)
|
||||
case models.ScrapedGroup:
|
||||
ret = append(ret, &m)
|
||||
// it's possible that a scraper returns models.ScrapedMovie
|
||||
case *models.ScrapedMovie:
|
||||
g := m.ScrapedGroup()
|
||||
ret = append(ret, &g)
|
||||
case models.ScrapedMovie:
|
||||
g := m.ScrapedGroup()
|
||||
ret = append(ret, &g)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedGroup", models.ErrConversion)
|
||||
}
|
||||
|
||||
@@ -9,12 +9,14 @@ import (
|
||||
type GalleryURLBuilder struct {
|
||||
BaseURL string
|
||||
GalleryID string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func NewGalleryURLBuilder(baseURL string, gallery *models.Gallery) GalleryURLBuilder {
|
||||
return GalleryURLBuilder{
|
||||
BaseURL: baseURL,
|
||||
GalleryID: strconv.Itoa(gallery.ID),
|
||||
UpdatedAt: strconv.FormatInt(gallery.UpdatedAt.Unix(), 10),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +25,5 @@ func (b GalleryURLBuilder) GetPreviewURL() string {
|
||||
}
|
||||
|
||||
func (b GalleryURLBuilder) GetCoverURL() string {
|
||||
return b.BaseURL + "/gallery/" + b.GalleryID + "/cover"
|
||||
return b.BaseURL + "/gallery/" + b.GalleryID + "/cover?t=" + b.UpdatedAt
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ var (
|
||||
defaultVideoExtensions = []string{"m4v", "mp4", "mov", "wmv", "avi", "mpg", "mpeg", "rmvb", "rm", "flv", "asf", "mkv", "webm", "f4v"}
|
||||
defaultImageExtensions = []string{"png", "jpg", "jpeg", "gif", "webp"}
|
||||
defaultGalleryExtensions = []string{"zip", "cbz"}
|
||||
defaultMenuItems = []string{"scenes", "images", "movies", "markers", "galleries", "performers", "studios", "tags"}
|
||||
defaultMenuItems = []string{"scenes", "images", "groups", "markers", "galleries", "performers", "studios", "tags"}
|
||||
)
|
||||
|
||||
type MissingConfigError struct {
|
||||
|
||||
@@ -60,6 +60,10 @@ func generateRegexps(patterns []string) []*regexp.Regexp {
|
||||
var fileRegexps []*regexp.Regexp
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if pattern == "" || pattern == " " {
|
||||
logger.Warnf("Skipping empty exclude pattern")
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(pattern, "(?i)") {
|
||||
pattern = "(?i)" + pattern
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ type InteractiveHeatmapSpeedGenerator struct {
|
||||
|
||||
type Script struct {
|
||||
// Version of Launchscript
|
||||
Version string `json:"version"`
|
||||
// #5600 - ignore version, don't validate type
|
||||
Version json.RawMessage `json:"version"`
|
||||
// Inverted causes up and down movement to be flipped.
|
||||
Inverted bool `json:"inverted,omitempty"`
|
||||
// Range is the percentage of a full stroke to use.
|
||||
@@ -40,7 +41,7 @@ type Script struct {
|
||||
// Action is a move at a specific time.
|
||||
type Action struct {
|
||||
// At time in milliseconds the action should fire.
|
||||
At int64 `json:"at"`
|
||||
At float64 `json:"at"`
|
||||
// Pos is the place in percent to move to.
|
||||
Pos int `json:"pos"`
|
||||
|
||||
@@ -109,8 +110,8 @@ func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string, sceneD
|
||||
// trim actions with negative timestamps to avoid index range errors when generating heatmap
|
||||
// #3181 - also trim actions that occur after the scene duration
|
||||
loggedBadTimestamp := false
|
||||
sceneDurationMilli := int64(sceneDuration * 1000)
|
||||
isValid := func(x int64) bool {
|
||||
sceneDurationMilli := sceneDuration * 1000
|
||||
isValid := func(x float64) bool {
|
||||
return x >= 0 && x < sceneDurationMilli
|
||||
}
|
||||
|
||||
@@ -132,7 +133,7 @@ func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string, sceneD
|
||||
|
||||
func (funscript *Script) UpdateIntensityAndSpeed() {
|
||||
|
||||
var t1, t2 int64
|
||||
var t1, t2 float64
|
||||
var p1, p2 int
|
||||
var intensity float64
|
||||
for i := range funscript.Actions {
|
||||
@@ -241,13 +242,13 @@ func (gt GradientTable) GetYRange(t float64) [2]float64 {
|
||||
|
||||
func (funscript Script) getGradientTable(numSegments int, sceneDurationMilli int64) GradientTable {
|
||||
const windowSize = 15
|
||||
const backfillThreshold = 500
|
||||
const backfillThreshold = float64(500)
|
||||
|
||||
segments := make([]struct {
|
||||
count int
|
||||
intensity int
|
||||
yRange [2]float64
|
||||
at int64
|
||||
at float64
|
||||
}, numSegments)
|
||||
gradient := make(GradientTable, numSegments)
|
||||
posList := []int{}
|
||||
@@ -297,7 +298,7 @@ func (funscript Script) getGradientTable(numSegments int, sceneDurationMilli int
|
||||
|
||||
// Fill in gaps in segments
|
||||
for i := 0; i < numSegments; i++ {
|
||||
segmentTS := (maxts / int64(numSegments)) * int64(i)
|
||||
segmentTS := float64((maxts / int64(numSegments)) * int64(i))
|
||||
|
||||
// Empty segment - fill it with the previous up to backfillThreshold ms
|
||||
if segments[i].count == 0 {
|
||||
@@ -406,7 +407,8 @@ func ConvertFunscriptToCSV(funscriptPath string) ([]byte, error) {
|
||||
pos = convertRange(pos, 0, funscript.Range, 0, 100)
|
||||
}
|
||||
|
||||
buffer.WriteString(fmt.Sprintf("%d,%d\r\n", action.At, pos))
|
||||
// I don't know whether the csv format requires int or float, so for now we'll use int
|
||||
buffer.WriteString(fmt.Sprintf("%d,%d\r\n", int(math.Round(action.At)), pos))
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package manager
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
"github.com/stashapp/stash/internal/static"
|
||||
@@ -46,14 +48,17 @@ func (s *SceneServer) StreamSceneDirect(scene *models.Scene, w http.ResponseWrit
|
||||
|
||||
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
|
||||
|
||||
filepath := GetInstance().Paths.Scene.GetStreamPath(scene.Path, sceneHash)
|
||||
fp := GetInstance().Paths.Scene.GetStreamPath(scene.Path, sceneHash)
|
||||
streamRequestCtx := ffmpeg.NewStreamRequestContext(w, r)
|
||||
|
||||
// #2579 - hijacking and closing the connection here causes video playback to fail in Safari
|
||||
// We trust that the request context will be closed, so we don't need to call Cancel on the
|
||||
// returned context here.
|
||||
_ = GetInstance().ReadLockManager.ReadLock(streamRequestCtx, filepath)
|
||||
http.ServeFile(w, r, filepath)
|
||||
_ = GetInstance().ReadLockManager.ReadLock(streamRequestCtx, fp)
|
||||
_, filename := filepath.Split(fp)
|
||||
contentDisposition := mime.FormatMediaType("inline", map[string]string{"filename": filename})
|
||||
w.Header().Set("Content-Disposition", contentDisposition)
|
||||
http.ServeFile(w, r, fp)
|
||||
}
|
||||
|
||||
func (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -32,6 +32,7 @@ func (t *GenerateCoverTask) Start(ctx context.Context) {
|
||||
return t.Scene.LoadPrimaryFile(ctx, r.File)
|
||||
}); err != nil {
|
||||
logger.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if !required {
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
@@ -64,12 +66,32 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
|
||||
args = args.Format("null")
|
||||
args = args.Output("-")
|
||||
|
||||
cmd := f.Command(ctx, args)
|
||||
// #6064 - add timeout to context to prevent hangs
|
||||
const hwTestTimeoutSecondsDefault = 1
|
||||
hwTestTimeoutSeconds := hwTestTimeoutSecondsDefault * time.Second
|
||||
|
||||
// allow timeout to be overridden with environment variable
|
||||
if timeout := os.Getenv("STASH_HW_TEST_TIMEOUT"); timeout != "" {
|
||||
if seconds, err := strconv.Atoi(timeout); err == nil {
|
||||
hwTestTimeoutSeconds = time.Duration(seconds) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
testCtx, cancel := context.WithTimeout(ctx, hwTestTimeoutSeconds)
|
||||
defer cancel()
|
||||
|
||||
cmd := f.Command(testCtx, args)
|
||||
logger.Tracef("[InitHWSupport] Testing codec %s: %v", codec, cmd.Args)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if testCtx.Err() != nil {
|
||||
logger.Debugf("[InitHWSupport] Codec %s test timed out after %d seconds", codec, hwTestTimeoutSeconds)
|
||||
continue
|
||||
}
|
||||
|
||||
errOutput := stderr.String()
|
||||
|
||||
if len(errOutput) == 0 {
|
||||
|
||||
@@ -107,7 +107,8 @@ func (s *scanJob) detectFolderMove(ctx context.Context, file scanFile) (*models.
|
||||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading info for %q: %w", path, err)
|
||||
logger.Errorf("reading info for %q: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !s.acceptEntry(ctx, path, info) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/rwcarlsen/goexif/exif"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
@@ -33,7 +34,7 @@ func areDimensionsFlipped(fs models.FS, path string) (bool, error) {
|
||||
|
||||
x, err := exif.Decode(r)
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
if errors.Is(err, io.EOF) || strings.Contains(err.Error(), "failed to find exif") {
|
||||
// no exif data
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -239,7 +239,8 @@ func (s *scanJob) queueFileFunc(ctx context.Context, f models.FS, zipFile *scanF
|
||||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading info for %q: %w", path, err)
|
||||
logger.Errorf("reading info for %q: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !s.acceptEntry(ctx, path, info) {
|
||||
|
||||
@@ -24,6 +24,7 @@ type FileFilterType struct {
|
||||
Basename *StringCriterionInput `json:"basename"`
|
||||
Dir *StringCriterionInput `json:"dir"`
|
||||
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder"`
|
||||
ZipFile *MultiCriterionInput `json:"zip_file"`
|
||||
ModTime *TimestampCriterionInput `json:"mod_time"`
|
||||
Duplicated *PHashDuplicationCriterionInput `json:"duplicated"`
|
||||
Hashes []*FingerprintFilterInput `json:"hashes"`
|
||||
|
||||
92
pkg/models/folder.go
Normal file
92
pkg/models/folder.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FolderQueryOptions struct {
|
||||
QueryOptions
|
||||
FolderFilter *FolderFilterType
|
||||
|
||||
TotalDuration bool
|
||||
Megapixels bool
|
||||
TotalSize bool
|
||||
}
|
||||
|
||||
type FolderFilterType struct {
|
||||
OperatorFilter[FolderFilterType]
|
||||
|
||||
Path *StringCriterionInput `json:"path,omitempty"`
|
||||
Basename *StringCriterionInput `json:"basename,omitempty"`
|
||||
// Filter by parent directory path
|
||||
Dir *StringCriterionInput `json:"dir,omitempty"`
|
||||
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"`
|
||||
ZipFile *MultiCriterionInput `json:"zip_file,omitempty"`
|
||||
// Filter by modification time
|
||||
ModTime *TimestampCriterionInput `json:"mod_time,omitempty"`
|
||||
GalleryCount *IntCriterionInput `json:"gallery_count,omitempty"`
|
||||
// Filter by files that meet this criteria
|
||||
FilesFilter *FileFilterType `json:"files_filter,omitempty"`
|
||||
// Filter by related galleries that meet this criteria
|
||||
GalleriesFilter *GalleryFilterType `json:"galleries_filter,omitempty"`
|
||||
// Filter by creation time
|
||||
CreatedAt *TimestampCriterionInput `json:"created_at,omitempty"`
|
||||
// Filter by last update time
|
||||
UpdatedAt *TimestampCriterionInput `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
func PathsFolderFilter(paths []string) *FileFilterType {
|
||||
if paths == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sep := string(filepath.Separator)
|
||||
|
||||
var ret *FileFilterType
|
||||
var or *FileFilterType
|
||||
for _, p := range paths {
|
||||
newOr := &FileFilterType{}
|
||||
if or != nil {
|
||||
or.Or = newOr
|
||||
} else {
|
||||
ret = newOr
|
||||
}
|
||||
|
||||
or = newOr
|
||||
|
||||
if !strings.HasSuffix(p, sep) {
|
||||
p += sep
|
||||
}
|
||||
|
||||
or.Path = &StringCriterionInput{
|
||||
Modifier: CriterionModifierEquals,
|
||||
Value: p + "%",
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
type FolderQueryResult struct {
|
||||
QueryResult[FolderID]
|
||||
|
||||
getter FolderGetter
|
||||
folders []*Folder
|
||||
resolveErr error
|
||||
}
|
||||
|
||||
func NewFolderQueryResult(folderGetter FolderGetter) *FolderQueryResult {
|
||||
return &FolderQueryResult{
|
||||
getter: folderGetter,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *FolderQueryResult) Resolve(ctx context.Context) ([]*Folder, error) {
|
||||
// cache results
|
||||
if r.folders == nil && r.resolveErr == nil {
|
||||
r.folders, r.resolveErr = r.getter.FindMany(ctx, r.IDs)
|
||||
}
|
||||
return r.folders, r.resolveErr
|
||||
}
|
||||
@@ -201,6 +201,29 @@ func (_m *FolderReaderWriter) FindMany(ctx context.Context, id []models.FolderID
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Query provides a mock function with given fields: ctx, options
|
||||
func (_m *FolderReaderWriter) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) {
|
||||
ret := _m.Called(ctx, options)
|
||||
|
||||
var r0 *models.FolderQueryResult
|
||||
if rf, ok := ret.Get(0).(func(context.Context, models.FolderQueryOptions) *models.FolderQueryResult); ok {
|
||||
r0 = rf(ctx, options)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*models.FolderQueryResult)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, models.FolderQueryOptions) error); ok {
|
||||
r1 = rf(ctx, options)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Update provides a mock function with given fields: ctx, f
|
||||
func (_m *FolderReaderWriter) Update(ctx context.Context, f *models.Folder) error {
|
||||
ret := _m.Called(ctx, f)
|
||||
|
||||
@@ -35,6 +35,14 @@ func (i FolderID) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(i.String()))
|
||||
}
|
||||
|
||||
func FolderIDsFromInts(ids []int) []FolderID {
|
||||
ret := make([]FolderID, len(ids))
|
||||
for i, id := range ids {
|
||||
ret[i] = FolderID(id)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// Folder represents a folder in the file system.
|
||||
type Folder struct {
|
||||
ID FolderID `json:"id"`
|
||||
|
||||
@@ -462,6 +462,7 @@ type ScrapedGroup struct {
|
||||
Date *string `json:"date"`
|
||||
Rating *string `json:"rating"`
|
||||
Director *string `json:"director"`
|
||||
URL *string `json:"url"` // included for backward compatibility
|
||||
URLs []string `json:"urls"`
|
||||
Synopsis *string `json:"synopsis"`
|
||||
Studio *ScrapedStudio `json:"studio"`
|
||||
|
||||
@@ -43,6 +43,9 @@ func (gp *generatedPaths) GetTmpPath(fileName string) string {
|
||||
// TempFile creates a temporary file using os.CreateTemp.
|
||||
// It is the equivalent of calling os.CreateTemp using Tmp and pattern.
|
||||
func (gp *generatedPaths) TempFile(pattern string) (*os.File, error) {
|
||||
if err := gp.EnsureTmpDir(); err != nil {
|
||||
logger.Warnf("Could not ensure existence of a temporary directory: %v", err)
|
||||
}
|
||||
return os.CreateTemp(gp.Tmp, pattern)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ type FolderFinder interface {
|
||||
FindByParentFolderID(ctx context.Context, parentFolderID FolderID) ([]*Folder, error)
|
||||
}
|
||||
|
||||
type FolderQueryer interface {
|
||||
Query(ctx context.Context, options FolderQueryOptions) (*FolderQueryResult, error)
|
||||
}
|
||||
|
||||
type FolderCounter interface {
|
||||
CountAllInPaths(ctx context.Context, p []string) (int, error)
|
||||
}
|
||||
@@ -48,6 +52,7 @@ type FolderFinderDestroyer interface {
|
||||
// FolderReader provides all methods to read folders.
|
||||
type FolderReader interface {
|
||||
FolderFinder
|
||||
FolderQueryer
|
||||
FolderCounter
|
||||
}
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ interface IPluginApi {
|
||||
);
|
||||
};
|
||||
|
||||
PluginApi.register.route("/plugin/test-react", TestPage);
|
||||
PluginApi.register.route("/plugins/test-react", TestPage);
|
||||
|
||||
PluginApi.patch.before("SettingsToolsSection", function (props: any) {
|
||||
const {
|
||||
@@ -206,7 +206,7 @@ interface IPluginApi {
|
||||
{props.children}
|
||||
<Setting
|
||||
heading={
|
||||
<Link to="/plugin/test-react">
|
||||
<Link to="/plugins/test-react">
|
||||
<Button>
|
||||
Test page
|
||||
</Button>
|
||||
@@ -232,7 +232,7 @@ interface IPluginApi {
|
||||
<NavLink
|
||||
className="nav-utility"
|
||||
exact
|
||||
to="/plugin/test-react"
|
||||
to="/plugins/test-react"
|
||||
>
|
||||
<Button
|
||||
className="minimal d-flex align-items-center h-100"
|
||||
|
||||
@@ -126,6 +126,7 @@ type mappedSceneScraperConfig struct {
|
||||
Performers mappedPerformerScraperConfig `yaml:"Performers"`
|
||||
Studio mappedConfig `yaml:"Studio"`
|
||||
Movies mappedConfig `yaml:"Movies"`
|
||||
Groups mappedConfig `yaml:"Groups"`
|
||||
}
|
||||
type _mappedSceneScraperConfig mappedSceneScraperConfig
|
||||
|
||||
@@ -134,6 +135,7 @@ const (
|
||||
mappedScraperConfigScenePerformers = "Performers"
|
||||
mappedScraperConfigSceneStudio = "Studio"
|
||||
mappedScraperConfigSceneMovies = "Movies"
|
||||
mappedScraperConfigSceneGroups = "Groups"
|
||||
)
|
||||
|
||||
func (s *mappedSceneScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||
@@ -151,11 +153,13 @@ func (s *mappedSceneScraperConfig) UnmarshalYAML(unmarshal func(interface{}) err
|
||||
thisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers]
|
||||
thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio]
|
||||
thisMap[mappedScraperConfigSceneMovies] = parentMap[mappedScraperConfigSceneMovies]
|
||||
thisMap[mappedScraperConfigSceneGroups] = parentMap[mappedScraperConfigSceneGroups]
|
||||
|
||||
delete(parentMap, mappedScraperConfigSceneTags)
|
||||
delete(parentMap, mappedScraperConfigScenePerformers)
|
||||
delete(parentMap, mappedScraperConfigSceneStudio)
|
||||
delete(parentMap, mappedScraperConfigSceneMovies)
|
||||
delete(parentMap, mappedScraperConfigSceneGroups)
|
||||
|
||||
// re-unmarshal the sub-fields
|
||||
yml, err := yaml.Marshal(thisMap)
|
||||
@@ -873,50 +877,55 @@ func (r mappedResult) apply(dest interface{}) {
|
||||
|
||||
func mapFieldValue(destVal reflect.Value, key string, value interface{}) error {
|
||||
field := destVal.FieldByName(key)
|
||||
|
||||
if !field.IsValid() {
|
||||
return fmt.Errorf("field %s does not exist on %s", key, destVal.Type().Name())
|
||||
}
|
||||
|
||||
if !field.CanSet() {
|
||||
return fmt.Errorf("field %s cannot be set on %s", key, destVal.Type().Name())
|
||||
}
|
||||
|
||||
fieldType := field.Type()
|
||||
|
||||
if field.IsValid() && field.CanSet() {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
// if the field is a pointer to a string, then we need to convert the string to a pointer
|
||||
// if the field is a string slice, then we need to convert the string to a slice
|
||||
switch {
|
||||
case fieldType.Kind() == reflect.String:
|
||||
field.SetString(v)
|
||||
case fieldType.Kind() == reflect.Ptr && fieldType.Elem().Kind() == reflect.String:
|
||||
ptr := reflect.New(fieldType.Elem())
|
||||
ptr.Elem().SetString(v)
|
||||
field.Set(ptr)
|
||||
case fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String:
|
||||
field.Set(reflect.ValueOf([]string{v}))
|
||||
default:
|
||||
return fmt.Errorf("cannot convert %T to %s", value, fieldType)
|
||||
}
|
||||
case []string:
|
||||
// expect the field to be a string slice
|
||||
if fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String {
|
||||
field.Set(reflect.ValueOf(v))
|
||||
} else {
|
||||
return fmt.Errorf("cannot convert %T to %s", value, fieldType)
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
// if the field is a pointer to a string, then we need to convert the string to a pointer
|
||||
// if the field is a string slice, then we need to convert the string to a slice
|
||||
switch {
|
||||
case fieldType.Kind() == reflect.String:
|
||||
field.SetString(v)
|
||||
case fieldType.Kind() == reflect.Ptr && fieldType.Elem().Kind() == reflect.String:
|
||||
ptr := reflect.New(fieldType.Elem())
|
||||
ptr.Elem().SetString(v)
|
||||
field.Set(ptr)
|
||||
case fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String:
|
||||
field.Set(reflect.ValueOf([]string{v}))
|
||||
default:
|
||||
// fallback to reflection
|
||||
reflectValue := reflect.ValueOf(value)
|
||||
reflectValueType := reflectValue.Type()
|
||||
|
||||
switch {
|
||||
case reflectValueType.ConvertibleTo(fieldType):
|
||||
field.Set(reflectValue.Convert(fieldType))
|
||||
case fieldType.Kind() == reflect.Pointer && reflectValueType.ConvertibleTo(fieldType.Elem()):
|
||||
ptr := reflect.New(fieldType.Elem())
|
||||
ptr.Elem().Set(reflectValue.Convert(fieldType.Elem()))
|
||||
field.Set(ptr)
|
||||
default:
|
||||
return fmt.Errorf("cannot convert %T to %s", value, fieldType)
|
||||
}
|
||||
return fmt.Errorf("cannot convert %T to %s", value, fieldType)
|
||||
}
|
||||
case []string:
|
||||
// expect the field to be a string slice
|
||||
if fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String {
|
||||
field.Set(reflect.ValueOf(v))
|
||||
} else {
|
||||
return fmt.Errorf("cannot convert %T to %s", value, fieldType)
|
||||
}
|
||||
default:
|
||||
// fallback to reflection
|
||||
reflectValue := reflect.ValueOf(value)
|
||||
reflectValueType := reflectValue.Type()
|
||||
|
||||
switch {
|
||||
case reflectValueType.ConvertibleTo(fieldType):
|
||||
field.Set(reflectValue.Convert(fieldType))
|
||||
case fieldType.Kind() == reflect.Pointer && reflectValueType.ConvertibleTo(fieldType.Elem()):
|
||||
ptr := reflect.New(fieldType.Elem())
|
||||
ptr.Elem().Set(reflectValue.Convert(fieldType.Elem()))
|
||||
field.Set(ptr)
|
||||
default:
|
||||
return fmt.Errorf("cannot convert %T to %s", value, fieldType)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("field does not exist or cannot be set")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1008,6 +1017,7 @@ func (s mappedScraper) processSceneRelationships(ctx context.Context, q mappedQu
|
||||
sceneTagsMap := sceneScraperConfig.Tags
|
||||
sceneStudioMap := sceneScraperConfig.Studio
|
||||
sceneMoviesMap := sceneScraperConfig.Movies
|
||||
sceneGroupsMap := sceneScraperConfig.Groups
|
||||
|
||||
ret.Performers = s.processPerformers(ctx, scenePerformersMap, q)
|
||||
|
||||
@@ -1034,7 +1044,12 @@ func (s mappedScraper) processSceneRelationships(ctx context.Context, q mappedQu
|
||||
ret.Movies = processRelationships[models.ScrapedMovie](ctx, s, sceneMoviesMap, q)
|
||||
}
|
||||
|
||||
return len(ret.Performers) > 0 || len(ret.Tags) > 0 || ret.Studio != nil || len(ret.Movies) > 0
|
||||
if sceneGroupsMap != nil {
|
||||
logger.Debug(`Processing scene groups:`)
|
||||
ret.Groups = processRelationships[models.ScrapedGroup](ctx, s, sceneGroupsMap, q)
|
||||
}
|
||||
|
||||
return len(ret.Performers) > 0 || len(ret.Tags) > 0 || ret.Studio != nil || len(ret.Movies) > 0 || len(ret.Groups) > 0
|
||||
}
|
||||
|
||||
func (s mappedScraper) processPerformers(ctx context.Context, performersMap mappedPerformerScraperConfig, q mappedQuery) []*models.ScrapedPerformer {
|
||||
|
||||
@@ -143,6 +143,21 @@ func (c Cache) postScrapeMovie(ctx context.Context, m models.ScrapedMovie, exclu
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// populate URL/URLs
|
||||
// if URLs are provided, only use those
|
||||
if len(m.URLs) > 0 {
|
||||
m.URL = &m.URLs[0]
|
||||
} else {
|
||||
urls := []string{}
|
||||
if m.URL != nil {
|
||||
urls = append(urls, *m.URL)
|
||||
}
|
||||
|
||||
if len(urls) > 0 {
|
||||
m.URLs = urls
|
||||
}
|
||||
}
|
||||
|
||||
// post-process - set the image if applicable
|
||||
if err := setMovieFrontImage(ctx, c.client, &m, c.globalConfig); err != nil {
|
||||
logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err)
|
||||
@@ -175,6 +190,21 @@ func (c Cache) postScrapeGroup(ctx context.Context, m models.ScrapedGroup, exclu
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// populate URL/URLs
|
||||
// if URLs are provided, only use those
|
||||
if len(m.URLs) > 0 {
|
||||
m.URL = &m.URLs[0]
|
||||
} else {
|
||||
urls := []string{}
|
||||
if m.URL != nil {
|
||||
urls = append(urls, *m.URL)
|
||||
}
|
||||
|
||||
if len(urls) > 0 {
|
||||
m.URLs = urls
|
||||
}
|
||||
}
|
||||
|
||||
// post-process - set the image if applicable
|
||||
if err := setGroupFrontImage(ctx, c.client, &m, c.globalConfig); err != nil {
|
||||
logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err)
|
||||
|
||||
@@ -350,12 +350,30 @@ func (db *Database) Backup(backupPath string) (err error) {
|
||||
defer thisDB.Close()
|
||||
}
|
||||
|
||||
logger.Infof("Backing up database into: %s", backupPath)
|
||||
_, err = thisDB.Exec(`VACUUM INTO "` + backupPath + `"`)
|
||||
// if backup path is not in the same directory as the database,
|
||||
// then backup to the same directory first, then move to the final location.
|
||||
// This is to prevent errors if the backup directory is over a network share.
|
||||
dbDir := filepath.Dir(db.dbPath)
|
||||
moveAfter := filepath.Dir(backupPath) != dbDir
|
||||
vacuumOut := backupPath
|
||||
if moveAfter {
|
||||
vacuumOut = filepath.Join(dbDir, filepath.Base(backupPath))
|
||||
}
|
||||
|
||||
logger.Infof("Backing up database into: %s", vacuumOut)
|
||||
_, err = thisDB.Exec(`VACUUM INTO "` + vacuumOut + `"`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("vacuum failed: %w", err)
|
||||
}
|
||||
|
||||
if moveAfter {
|
||||
logger.Infof("Moving database backup to: %s", backupPath)
|
||||
err = fsutil.SafeMove(vacuumOut, backupPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("moving database backup failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -321,7 +321,7 @@ type FileStore struct {
|
||||
func NewFileStore() *FileStore {
|
||||
return &FileStore{
|
||||
repository: repository{
|
||||
tableName: sceneTable,
|
||||
tableName: fileTable,
|
||||
idColumn: idColumn,
|
||||
},
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ func (qb *fileFilterHandler) criterionHandler() criterionHandler {
|
||||
×tampCriterionHandler{fileFilter.ModTime, "files.mod_time", nil},
|
||||
|
||||
qb.parentFolderCriterionHandler(fileFilter.ParentFolder),
|
||||
qb.zipFileCriterionHandler(fileFilter.ZipFile),
|
||||
|
||||
qb.sceneCountCriterionHandler(fileFilter.SceneCount),
|
||||
qb.imageCountCriterionHandler(fileFilter.ImageCount),
|
||||
@@ -106,6 +107,43 @@ func (qb *fileFilterHandler) criterionHandler() criterionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) zipFileCriterionHandler(criterion *models.MultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if criterion != nil {
|
||||
if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {
|
||||
var notClause string
|
||||
if criterion.Modifier == models.CriterionModifierNotNull {
|
||||
notClause = "NOT"
|
||||
}
|
||||
|
||||
f.addWhere(fmt.Sprintf("files.zip_file_id IS %s NULL", notClause))
|
||||
return
|
||||
}
|
||||
|
||||
if len(criterion.Value) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var args []interface{}
|
||||
for _, tagID := range criterion.Value {
|
||||
args = append(args, tagID)
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
havingClause := ""
|
||||
switch criterion.Modifier {
|
||||
case models.CriterionModifierIncludes:
|
||||
whereClause = "files.zip_file_id IN " + getInBinding(len(criterion.Value))
|
||||
case models.CriterionModifierExcludes:
|
||||
whereClause = "files.zip_file_id NOT IN " + getInBinding(len(criterion.Value))
|
||||
}
|
||||
|
||||
f.addWhere(whereClause, args...)
|
||||
f.addHaving(havingClause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if folder == nil {
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestFileQuery(t *testing.T) {
|
||||
findFilter *models.FindFilterType
|
||||
filter *models.FileFilterType
|
||||
includeIdxs []int
|
||||
includeIDs []int
|
||||
includeIDs []models.FileID
|
||||
excludeIdxs []int
|
||||
wantErr bool
|
||||
}{
|
||||
@@ -52,7 +52,7 @@ func TestFileQuery(t *testing.T) {
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIDs: []int{int(sceneFileIDs[sceneIdxWithGroup])},
|
||||
includeIDs: []models.FileID{sceneFileIDs[sceneIdxWithGroup]},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
@@ -65,7 +65,20 @@ func TestFileQuery(t *testing.T) {
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIDs: []int{int(sceneFileIDs[sceneIdxWithGroup])},
|
||||
includeIDs: []models.FileID{sceneFileIDs[sceneIdxWithGroup]},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
name: "zip file",
|
||||
filter: &models.FileFilterType{
|
||||
ZipFile: &models.MultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(int(fileIDs[fileIdxZip])),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIDs: []models.FileID{fileIDs[fileIdxInZip]},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
// TODO - add more tests for other file filters
|
||||
@@ -86,15 +99,18 @@ func TestFileQuery(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
include := indexesToIDs(sceneIDs, tt.includeIdxs)
|
||||
include = append(include, tt.includeIDs...)
|
||||
exclude := indexesToIDs(sceneIDs, tt.excludeIdxs)
|
||||
include := indexesToIDPtrs(fileIDs, tt.includeIdxs)
|
||||
for _, id := range tt.includeIDs {
|
||||
v := id
|
||||
include = append(include, &v)
|
||||
}
|
||||
exclude := indexesToIDPtrs(fileIDs, tt.excludeIdxs)
|
||||
|
||||
for _, i := range include {
|
||||
assert.Contains(results.IDs, models.FileID(i))
|
||||
assert.Contains(results.IDs, models.FileID(*i))
|
||||
}
|
||||
for _, e := range exclude {
|
||||
assert.NotContains(results.IDs, models.FileID(e))
|
||||
assert.NotContains(results.IDs, models.FileID(*e))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
)
|
||||
|
||||
const folderTable = "folders"
|
||||
const folderIDColumn = "folder_id"
|
||||
|
||||
type folderRow struct {
|
||||
ID models.FolderID `db:"id" goqu:"skipinsert"`
|
||||
@@ -83,6 +84,25 @@ func (r folderQueryRows) resolve() []*models.Folder {
|
||||
return ret
|
||||
}
|
||||
|
||||
type folderRepositoryType struct {
|
||||
repository
|
||||
|
||||
galleries repository
|
||||
}
|
||||
|
||||
var (
|
||||
folderRepository = folderRepositoryType{
|
||||
repository: repository{
|
||||
tableName: folderTable,
|
||||
idColumn: idColumn,
|
||||
},
|
||||
galleries: repository{
|
||||
tableName: galleryTable,
|
||||
idColumn: folderIDColumn,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type FolderStore struct {
|
||||
repository
|
||||
|
||||
@@ -92,7 +112,7 @@ type FolderStore struct {
|
||||
func NewFolderStore() *FolderStore {
|
||||
return &FolderStore{
|
||||
repository: repository{
|
||||
tableName: sceneTable,
|
||||
tableName: folderTable,
|
||||
idColumn: idColumn,
|
||||
},
|
||||
|
||||
@@ -360,3 +380,162 @@ func (qb *FolderStore) FindByZipFileID(ctx context.Context, zipFileID models.Fil
|
||||
|
||||
return qb.getMany(ctx, q)
|
||||
}
|
||||
|
||||
func (qb *FolderStore) validateFilter(fileFilter *models.FolderFilterType) error {
|
||||
const and = "AND"
|
||||
const or = "OR"
|
||||
const not = "NOT"
|
||||
|
||||
if fileFilter.And != nil {
|
||||
if fileFilter.Or != nil {
|
||||
return illegalFilterCombination(and, or)
|
||||
}
|
||||
if fileFilter.Not != nil {
|
||||
return illegalFilterCombination(and, not)
|
||||
}
|
||||
|
||||
return qb.validateFilter(fileFilter.And)
|
||||
}
|
||||
|
||||
if fileFilter.Or != nil {
|
||||
if fileFilter.Not != nil {
|
||||
return illegalFilterCombination(or, not)
|
||||
}
|
||||
|
||||
return qb.validateFilter(fileFilter.Or)
|
||||
}
|
||||
|
||||
if fileFilter.Not != nil {
|
||||
return qb.validateFilter(fileFilter.Not)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) makeFilter(ctx context.Context, folderFilter *models.FolderFilterType) *filterBuilder {
|
||||
query := &filterBuilder{}
|
||||
|
||||
if folderFilter.And != nil {
|
||||
query.and(qb.makeFilter(ctx, folderFilter.And))
|
||||
}
|
||||
if folderFilter.Or != nil {
|
||||
query.or(qb.makeFilter(ctx, folderFilter.Or))
|
||||
}
|
||||
if folderFilter.Not != nil {
|
||||
query.not(qb.makeFilter(ctx, folderFilter.Not))
|
||||
}
|
||||
|
||||
filter := filterBuilderFromHandler(ctx, &folderFilterHandler{
|
||||
folderFilter: folderFilter,
|
||||
})
|
||||
|
||||
return filter
|
||||
}
|
||||
|
||||
func (qb *FolderStore) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) {
|
||||
folderFilter := options.FolderFilter
|
||||
findFilter := options.FindFilter
|
||||
|
||||
if folderFilter == nil {
|
||||
folderFilter = &models.FolderFilterType{}
|
||||
}
|
||||
if findFilter == nil {
|
||||
findFilter = &models.FindFilterType{}
|
||||
}
|
||||
|
||||
query := qb.newQuery()
|
||||
|
||||
distinctIDs(&query, folderTable)
|
||||
|
||||
if q := findFilter.Q; q != nil && *q != "" {
|
||||
searchColumns := []string{"folders.path"}
|
||||
query.parseQueryString(searchColumns, *q)
|
||||
}
|
||||
|
||||
if err := qb.validateFilter(folderFilter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filter := qb.makeFilter(ctx, folderFilter)
|
||||
|
||||
if err := query.addFilter(filter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := qb.setQuerySort(&query, findFilter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query.sortAndPagination += getPagination(findFilter)
|
||||
|
||||
result, err := qb.queryGroupedFields(ctx, options, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error querying aggregate fields: %w", err)
|
||||
}
|
||||
|
||||
idsResult, err := query.findIDs(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error finding IDs: %w", err)
|
||||
}
|
||||
|
||||
result.IDs = make([]models.FolderID, len(idsResult))
|
||||
for i, id := range idsResult {
|
||||
result.IDs[i] = models.FolderID(id)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) queryGroupedFields(ctx context.Context, options models.FolderQueryOptions, query queryBuilder) (*models.FolderQueryResult, error) {
|
||||
if !options.Count {
|
||||
// nothing to do - return empty result
|
||||
return models.NewFolderQueryResult(qb), nil
|
||||
}
|
||||
|
||||
aggregateQuery := qb.newQuery()
|
||||
|
||||
if options.Count {
|
||||
aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total")
|
||||
}
|
||||
|
||||
const includeSortPagination = false
|
||||
aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination))
|
||||
|
||||
out := struct {
|
||||
Total int
|
||||
Duration float64
|
||||
Megapixels float64
|
||||
Size int64
|
||||
}{}
|
||||
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret := models.NewFolderQueryResult(qb)
|
||||
ret.Count = out.Total
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
var folderSortOptions = sortOptions{
|
||||
"created_at",
|
||||
"id",
|
||||
"path",
|
||||
"random",
|
||||
"updated_at",
|
||||
}
|
||||
|
||||
func (qb *FolderStore) setQuerySort(query *queryBuilder, findFilter *models.FindFilterType) error {
|
||||
if findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == "" {
|
||||
return nil
|
||||
}
|
||||
sort := findFilter.GetSort("path")
|
||||
|
||||
// CVE-2024-32231 - ensure sort is in the list of allowed sorts
|
||||
if err := folderSortOptions.validateSort(sort); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
direction := findFilter.GetDirection()
|
||||
query.sortAndPagination += getSort(sort, direction, "folders")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
150
pkg/sqlite/folder_filter.go
Normal file
150
pkg/sqlite/folder_filter.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
type folderFilterHandler struct {
|
||||
folderFilter *models.FolderFilterType
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) validate() error {
|
||||
folderFilter := qb.folderFilter
|
||||
if folderFilter == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := validateFilterCombination(folderFilter.OperatorFilter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if subFilter := folderFilter.SubFilter(); subFilter != nil {
|
||||
sqb := &folderFilterHandler{folderFilter: subFilter}
|
||||
if err := sqb.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) handle(ctx context.Context, f *filterBuilder) {
|
||||
folderFilter := qb.folderFilter
|
||||
if folderFilter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := qb.validate(); err != nil {
|
||||
f.setError(err)
|
||||
return
|
||||
}
|
||||
|
||||
sf := folderFilter.SubFilter()
|
||||
if sf != nil {
|
||||
sub := &folderFilterHandler{sf}
|
||||
handleSubFilter(ctx, sub, f, folderFilter.OperatorFilter)
|
||||
}
|
||||
|
||||
f.handleCriterion(ctx, qb.criterionHandler())
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) criterionHandler() criterionHandler {
|
||||
folderFilter := qb.folderFilter
|
||||
return compoundHandler{
|
||||
stringCriterionHandler(folderFilter.Path, "folders.path"),
|
||||
×tampCriterionHandler{folderFilter.ModTime, "folders.mod_time", nil},
|
||||
|
||||
qb.parentFolderCriterionHandler(folderFilter.ParentFolder),
|
||||
qb.zipFileCriterionHandler(folderFilter.ZipFile),
|
||||
|
||||
qb.galleryCountCriterionHandler(folderFilter.GalleryCount),
|
||||
|
||||
×tampCriterionHandler{folderFilter.CreatedAt, "folders.created_at", nil},
|
||||
×tampCriterionHandler{folderFilter.UpdatedAt, "folders.updated_at", nil},
|
||||
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "galleries.id",
|
||||
relatedRepo: galleryRepository.repository,
|
||||
relatedHandler: &galleryFilterHandler{folderFilter.GalleriesFilter},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
folderRepository.galleries.innerJoin(f, "", "folders.id")
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) zipFileCriterionHandler(criterion *models.MultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if criterion != nil {
|
||||
if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {
|
||||
var notClause string
|
||||
if criterion.Modifier == models.CriterionModifierNotNull {
|
||||
notClause = "NOT"
|
||||
}
|
||||
|
||||
f.addWhere(fmt.Sprintf("folders.zip_file_id IS %s NULL", notClause))
|
||||
return
|
||||
}
|
||||
|
||||
if len(criterion.Value) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var args []interface{}
|
||||
for _, tagID := range criterion.Value {
|
||||
args = append(args, tagID)
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
havingClause := ""
|
||||
switch criterion.Modifier {
|
||||
case models.CriterionModifierIncludes:
|
||||
whereClause = "folders.zip_file_id IN " + getInBinding(len(criterion.Value))
|
||||
case models.CriterionModifierExcludes:
|
||||
whereClause = "folders.zip_file_id NOT IN " + getInBinding(len(criterion.Value))
|
||||
}
|
||||
|
||||
f.addWhere(whereClause, args...)
|
||||
f.addHaving(havingClause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if folder == nil {
|
||||
return
|
||||
}
|
||||
|
||||
folderCopy := *folder
|
||||
switch folderCopy.Modifier {
|
||||
case models.CriterionModifierEquals:
|
||||
folderCopy.Modifier = models.CriterionModifierIncludesAll
|
||||
case models.CriterionModifierNotEquals:
|
||||
folderCopy.Modifier = models.CriterionModifierExcludes
|
||||
}
|
||||
|
||||
hh := hierarchicalMultiCriterionHandlerBuilder{
|
||||
primaryTable: folderTable,
|
||||
foreignTable: folderTable,
|
||||
foreignFK: "parent_folder_id",
|
||||
parentFK: "parent_folder_id",
|
||||
}
|
||||
|
||||
hh.handler(&folderCopy)(ctx, f)
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) galleryCountCriterionHandler(galleryCount *models.IntCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if galleryCount != nil {
|
||||
f.addLeftJoin("galleries", "", "galleries.folder_id = folders.id")
|
||||
clause, args := getIntCriterionWhereClause("count(distinct galleries.id)", *galleryCount)
|
||||
|
||||
f.addHaving(clause, args...)
|
||||
}
|
||||
}
|
||||
}
|
||||
95
pkg/sqlite/folder_filter_test.go
Normal file
95
pkg/sqlite/folder_filter_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFolderQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
findFilter *models.FindFilterType
|
||||
filter *models.FolderFilterType
|
||||
includeIdxs []int
|
||||
includeIDs []models.FolderID
|
||||
excludeIdxs []int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "path",
|
||||
filter: &models.FolderFilterType{
|
||||
Path: &models.StringCriterionInput{
|
||||
Value: getFolderPath(folderIdxWithSubFolder, nil),
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{folderIdxWithSubFolder, folderIdxWithParentFolder},
|
||||
excludeIdxs: []int{folderIdxInZip},
|
||||
},
|
||||
{
|
||||
name: "parent folder",
|
||||
filter: &models.FolderFilterType{
|
||||
ParentFolder: &models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(int(folderIDs[folderIdxWithSubFolder])),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{folderIdxWithParentFolder},
|
||||
excludeIdxs: []int{folderIdxWithSubFolder, folderIdxInZip},
|
||||
},
|
||||
{
|
||||
name: "zip file",
|
||||
filter: &models.FolderFilterType{
|
||||
ZipFile: &models.MultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(int(fileIDs[fileIdxZip])),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{folderIdxInZip},
|
||||
excludeIdxs: []int{folderIdxForObjectFiles},
|
||||
},
|
||||
// TODO - add more tests for other folder filters
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
results, err := db.Folder.Query(ctx, models.FolderQueryOptions{
|
||||
FolderFilter: tt.filter,
|
||||
QueryOptions: models.QueryOptions{
|
||||
FindFilter: tt.findFilter,
|
||||
},
|
||||
})
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
include := indexesToIDPtrs(folderIDs, tt.includeIdxs)
|
||||
for _, id := range tt.includeIDs {
|
||||
v := id
|
||||
include = append(include, &v)
|
||||
}
|
||||
exclude := indexesToIDPtrs(folderIDs, tt.excludeIdxs)
|
||||
|
||||
for _, i := range include {
|
||||
assert.Contains(results.IDs, models.FolderID(*i))
|
||||
}
|
||||
for _, e := range exclude {
|
||||
assert.NotContains(results.IDs, models.FolderID(*e))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,7 @@ var (
|
||||
},
|
||||
fkColumn: "tag_id",
|
||||
foreignTable: tagTable,
|
||||
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
|
||||
orderBy: tagTableSortSQL,
|
||||
},
|
||||
images: joinRepository{
|
||||
repository: repository{
|
||||
|
||||
@@ -481,7 +481,7 @@ func Test_galleryQueryBuilder_UpdatePartial(t *testing.T) {
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
SceneIDs: models.NewRelatedIDs([]int{sceneIDs[sceneIdxWithGallery]}),
|
||||
TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithGallery], tagIDs[tagIdx1WithDupName]}),
|
||||
TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGallery]}),
|
||||
PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithGallery], performerIDs[performerIdx1WithDupName]}),
|
||||
},
|
||||
false,
|
||||
|
||||
@@ -122,7 +122,7 @@ var (
|
||||
},
|
||||
fkColumn: tagIDColumn,
|
||||
foreignTable: tagTable,
|
||||
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
|
||||
orderBy: tagTableSortSQL,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -177,7 +177,7 @@ var (
|
||||
},
|
||||
fkColumn: tagIDColumn,
|
||||
foreignTable: tagTable,
|
||||
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
|
||||
orderBy: tagTableSortSQL,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -189,7 +189,7 @@ var (
|
||||
},
|
||||
fkColumn: tagIDColumn,
|
||||
foreignTable: tagTable,
|
||||
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
|
||||
orderBy: tagTableSortSQL,
|
||||
},
|
||||
stashIDs: stashIDRepository{
|
||||
repository{
|
||||
|
||||
@@ -282,7 +282,7 @@ func Test_PerformerStore_Update(t *testing.T) {
|
||||
Weight: &weight,
|
||||
IgnoreAutoTag: ignoreAutoTag,
|
||||
Aliases: models.NewRelatedStrings(aliases),
|
||||
TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithPerformer], tagIDs[tagIdx1WithDupName]}),
|
||||
TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithPerformer]}),
|
||||
StashIDs: models.NewRelatedStashIDs([]models.StashID{
|
||||
{
|
||||
StashID: stashID1,
|
||||
@@ -516,7 +516,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
|
||||
Weight: models.NewOptionalInt(weight),
|
||||
IgnoreAutoTag: models.NewOptionalBool(ignoreAutoTag),
|
||||
TagIDs: &models.UpdateIDs{
|
||||
IDs: []int{tagIDs[tagIdx1WithPerformer], tagIDs[tagIdx1WithDupName]},
|
||||
IDs: []int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithPerformer]},
|
||||
Mode: models.RelationshipUpdateModeSet,
|
||||
},
|
||||
StashIDs: &models.UpdateStashIDs{
|
||||
@@ -563,7 +563,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
|
||||
HairColor: hairColor,
|
||||
Weight: &weight,
|
||||
IgnoreAutoTag: ignoreAutoTag,
|
||||
TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithPerformer], tagIDs[tagIdx1WithDupName]}),
|
||||
TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithPerformer]}),
|
||||
StashIDs: models.NewRelatedStashIDs([]models.StashID{
|
||||
{
|
||||
StashID: stashID1,
|
||||
|
||||
@@ -201,7 +201,7 @@ var (
|
||||
},
|
||||
fkColumn: tagIDColumn,
|
||||
foreignTable: tagTable,
|
||||
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
|
||||
orderBy: tagTableSortSQL,
|
||||
},
|
||||
performers: joinRepository{
|
||||
repository: repository{
|
||||
|
||||
@@ -319,7 +319,7 @@ func (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) crite
|
||||
f.addWhere("galleries_join.scene_id IS NULL")
|
||||
case "studio":
|
||||
f.addWhere("scenes.studio_id IS NULL")
|
||||
case "movie":
|
||||
case "movie", "group":
|
||||
sceneRepository.groups.join(f, "groups_join", "scenes.id")
|
||||
f.addWhere("groups_join.scene_id IS NULL")
|
||||
case "performers":
|
||||
|
||||
@@ -578,6 +578,22 @@ func indexToID(ids []int, idx int) int {
|
||||
return ids[idx]
|
||||
}
|
||||
|
||||
func indexesToIDPtrs[T any](ids []T, indexes []int) []*T {
|
||||
ret := make([]*T, len(indexes))
|
||||
for i, idx := range indexes {
|
||||
ret[i] = indexToIDPtr(ids, idx)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func indexToIDPtr[T any](ids []T, idx int) *T {
|
||||
if idx < 0 {
|
||||
return nil
|
||||
}
|
||||
return &ids[idx]
|
||||
}
|
||||
|
||||
func indexFromID(ids []int, id int) int {
|
||||
for i, v := range ids {
|
||||
if v == id {
|
||||
@@ -675,7 +691,9 @@ func populateDB() error {
|
||||
return fmt.Errorf("creating files: %w", err)
|
||||
}
|
||||
|
||||
// TODO - link folders to zip files
|
||||
if err := linkFoldersToZip(ctx); err != nil {
|
||||
return fmt.Errorf("linking folders to zip files: %w", err)
|
||||
}
|
||||
|
||||
if err := createTags(ctx, db.Tag, tagsNameCase, tagsNameNoCase); err != nil {
|
||||
return fmt.Errorf("error creating tags: %s", err.Error())
|
||||
@@ -798,6 +816,27 @@ func createFolders(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func linkFoldersToZip(ctx context.Context) error {
|
||||
// link folders to zip files
|
||||
for folderIdx, fileIdx := range folderZipFiles {
|
||||
folderID := folderIDs[folderIdx]
|
||||
fileID := fileIDs[fileIdx]
|
||||
|
||||
f, err := db.Folder.Find(ctx, folderID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error finding folder [%d] to link to zip file [%d]", folderID, fileID)
|
||||
}
|
||||
|
||||
f.ZipFileID = &fileID
|
||||
|
||||
if err := db.Folder.Update(ctx, f); err != nil {
|
||||
return fmt.Errorf("Error linking folder [%d] to zip file [%d]: %s", folderIdx, fileIdx, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFileBaseName(index int) string {
|
||||
return getPrefixedStringValue("file", index, "basename")
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ var (
|
||||
},
|
||||
fkColumn: tagIDColumn,
|
||||
foreignTable: tagTable,
|
||||
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
|
||||
orderBy: tagTableSortSQL,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -76,7 +76,7 @@ var (
|
||||
},
|
||||
fkColumn: imagesTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
orderBy: tagTableSort,
|
||||
}
|
||||
|
||||
imagesPerformersTableMgr = &joinTable{
|
||||
@@ -116,7 +116,7 @@ var (
|
||||
},
|
||||
fkColumn: galleriesTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
orderBy: tagTableSort,
|
||||
}
|
||||
|
||||
galleriesPerformersTableMgr = &joinTable{
|
||||
@@ -174,7 +174,7 @@ var (
|
||||
},
|
||||
fkColumn: scenesTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
orderBy: tagTableSort,
|
||||
}
|
||||
|
||||
scenesPerformersTableMgr = &joinTable{
|
||||
@@ -282,7 +282,7 @@ var (
|
||||
},
|
||||
fkColumn: performersTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
orderBy: tagTableSort,
|
||||
}
|
||||
|
||||
performersStashIDsTableMgr = &stashIDTable{
|
||||
@@ -314,7 +314,7 @@ var (
|
||||
},
|
||||
fkColumn: studiosTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
orderBy: tagTableSort,
|
||||
}
|
||||
|
||||
studiosStashIDsTableMgr = &stashIDTable{
|
||||
@@ -331,6 +331,10 @@ var (
|
||||
idColumn: goqu.T(tagTable).Col(idColumn),
|
||||
}
|
||||
|
||||
// formerly: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc()
|
||||
tagTableSort = goqu.L("COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI").Asc()
|
||||
tagTableSortSQL = "COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI ASC"
|
||||
|
||||
tagsAliasesTableMgr = &stringTable{
|
||||
table: table{
|
||||
table: tagsAliasesJoinTable,
|
||||
@@ -346,7 +350,7 @@ var (
|
||||
},
|
||||
fkColumn: tagRelationsJoinTable.Col(tagParentIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
orderBy: tagTableSort,
|
||||
}
|
||||
|
||||
tagsChildTagsTableMgr = *tagsParentTagsTableMgr.invert()
|
||||
@@ -373,7 +377,7 @@ var (
|
||||
},
|
||||
fkColumn: groupsTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
orderBy: tagTableSort,
|
||||
}
|
||||
|
||||
groupRelationshipTableMgr = &table{
|
||||
|
||||
@@ -790,6 +790,7 @@ func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) er
|
||||
imagesTagsTable: imageIDColumn,
|
||||
"performers_tags": "performer_id",
|
||||
"studios_tags": "studio_id",
|
||||
groupsTagsTable: "group_id",
|
||||
}
|
||||
|
||||
args = append(args, destination)
|
||||
|
||||
@@ -931,6 +931,8 @@ func TestTagMerge(t *testing.T) {
|
||||
tagIdxWithGallery,
|
||||
tagIdx1WithGallery,
|
||||
tagIdx2WithGallery,
|
||||
tagIdx1WithGroup,
|
||||
tagIdx2WithGroup,
|
||||
}
|
||||
var srcIDs []int
|
||||
for _, idx := range srcIdxs {
|
||||
@@ -1024,6 +1026,18 @@ func TestTagMerge(t *testing.T) {
|
||||
|
||||
assert.Contains(studioTagIDs, destID)
|
||||
|
||||
// ensure group points to new tag
|
||||
group, err := db.Group.Find(ctx, groupIDs[groupIdxWithTwoTags])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := group.LoadTagIDs(ctx, db.Group); err != nil {
|
||||
return err
|
||||
}
|
||||
groupTagIDs := group.TagIDs.List()
|
||||
|
||||
assert.Contains(groupTagIDs, destID)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Error(err.Error())
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
xhr.onerror = function() {
|
||||
document.getElementsByClassName("login-error")[0].innerHTML = localeStrings.internal_error;
|
||||
};
|
||||
xhr.send("username=" + username + "&password=" + password + "&returnURL=" + returnURL);
|
||||
var body = "username=" + encodeURIComponent(username) + "&password=" + encodeURIComponent(password) + "&returnURL=" + encodeURIComponent(returnURL);
|
||||
xhr.send(body);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -33,7 +33,11 @@ import V0250 from "src/docs/en/Changelog/v0250.md";
|
||||
import V0260 from "src/docs/en/Changelog/v0260.md";
|
||||
import V0270 from "src/docs/en/Changelog/v0270.md";
|
||||
import V0280 from "src/docs/en/Changelog/v0280.md";
|
||||
import V0290 from "src/docs/en/Changelog/v0290.md";
|
||||
|
||||
import V020ReleaseNotes from "src/docs/en/ReleaseNotes/v0290.md";
|
||||
import { MarkdownPage } from "../Shared/MarkdownPage";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
const Changelog: React.FC = () => {
|
||||
const [{ data, loading }, setOpenState] = useChangelogStorage();
|
||||
@@ -63,14 +67,15 @@ const Changelog: React.FC = () => {
|
||||
date?: string;
|
||||
page: string;
|
||||
defaultOpen?: boolean;
|
||||
releaseNotes?: string;
|
||||
}
|
||||
|
||||
// after new release:
|
||||
// add entry to releases, using the current* fields
|
||||
// then update the current fields.
|
||||
const currentVersion = stashVersion || "v0.28.1";
|
||||
const currentVersion = stashVersion || "v0.29.0";
|
||||
const currentDate = buildDate;
|
||||
const currentPage = V0280;
|
||||
const currentPage = V0290;
|
||||
|
||||
const releases: IStashRelease[] = [
|
||||
{
|
||||
@@ -78,6 +83,12 @@ const Changelog: React.FC = () => {
|
||||
date: currentDate,
|
||||
page: currentPage,
|
||||
defaultOpen: true,
|
||||
releaseNotes: V020ReleaseNotes,
|
||||
},
|
||||
{
|
||||
version: "v0.28.1",
|
||||
date: "2025-03-20",
|
||||
page: V0280,
|
||||
},
|
||||
{
|
||||
version: "v0.27.2",
|
||||
@@ -248,6 +259,15 @@ const Changelog: React.FC = () => {
|
||||
setOpenState={setVersionOpenState}
|
||||
defaultOpen={r.defaultOpen}
|
||||
>
|
||||
{r.releaseNotes && (
|
||||
<div>
|
||||
<h3 className="mt-0">
|
||||
<FormattedMessage id="release_notes" />
|
||||
</h3>
|
||||
<MarkdownPage page={r.releaseNotes} />
|
||||
<hr />
|
||||
</div>
|
||||
)}
|
||||
<MarkdownPage page={r.page} />
|
||||
</Version>
|
||||
))}
|
||||
|
||||
@@ -43,6 +43,7 @@ import cx from "classnames";
|
||||
import { useRatingKeybinds } from "src/hooks/keybinds";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { TruncatedText } from "src/components/Shared/TruncatedText";
|
||||
import { goBackOrReplace } from "src/utils/history";
|
||||
|
||||
interface IProps {
|
||||
gallery: GQL.GalleryDataFragment;
|
||||
@@ -167,7 +168,7 @@ export const GalleryPage: React.FC<IProps> = ({ gallery, add }) => {
|
||||
function onDeleteDialogClosed(deleted: boolean) {
|
||||
setIsDeleteAlertOpen(false);
|
||||
if (deleted) {
|
||||
history.goBack();
|
||||
goBackOrReplace(history, "/galleries");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +162,21 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
||||
);
|
||||
}, [scrapers]);
|
||||
|
||||
const cover = useMemo(() => {
|
||||
if (gallery?.paths?.cover) {
|
||||
return (
|
||||
<div className="gallery-cover">
|
||||
<img
|
||||
src={gallery.paths.cover}
|
||||
alt={intl.formatMessage({ id: "cover_image" })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div></div>;
|
||||
}, [gallery?.paths?.cover, intl]);
|
||||
|
||||
async function onSave(input: InputValues) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -463,6 +478,12 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
||||
</Col>
|
||||
<Col lg={5} xl={12}>
|
||||
{renderDetailsField()}
|
||||
<Form.Group controlId="cover_image">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="cover_image" />
|
||||
</Form.Label>
|
||||
{cover}
|
||||
</Form.Group>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
|
||||
@@ -149,7 +149,7 @@ export const GalleryList: React.FC<IGalleryList> = ({
|
||||
if (filter.displayMode === DisplayMode.Wall) {
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="GalleryWall">
|
||||
<div className={`GalleryWall zoom-${filter.zoomIndex}`}>
|
||||
{result.data.findGalleries.galleries.map((gallery) => (
|
||||
<GalleryWallCard key={gallery.id} gallery={gallery} />
|
||||
))}
|
||||
|
||||
@@ -67,8 +67,8 @@ export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {
|
||||
images.forEach((image, index) => {
|
||||
let imageData = {
|
||||
src: image.paths.thumbnail!,
|
||||
width: image.visual_files[0].width,
|
||||
height: image.visual_files[0].height,
|
||||
width: image.visual_files[0]?.width ?? 0,
|
||||
height: image.visual_files[0]?.height ?? 0,
|
||||
tabIndex: index,
|
||||
key: image.id ?? index,
|
||||
loading: "lazy",
|
||||
|
||||
@@ -206,7 +206,22 @@ $galleryTabWidth: 450px;
|
||||
}
|
||||
}
|
||||
|
||||
.GalleryWall {
|
||||
.gallery-cover {
|
||||
aspect-ratio: 4 / 3;
|
||||
display: block;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gallery-cover img {
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
div.GalleryWall {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 auto;
|
||||
@@ -249,28 +264,6 @@ $galleryTabWidth: 450px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@mixin galleryWidth($width) {
|
||||
height: math.div($width, 3) * 2;
|
||||
|
||||
&-landscape {
|
||||
width: $width;
|
||||
}
|
||||
|
||||
&-portrait {
|
||||
width: math.div($width, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
@include galleryWidth(96vw);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
@include galleryWidth(48vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
@include galleryWidth(32vw);
|
||||
}
|
||||
|
||||
&-img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
@@ -355,6 +348,62 @@ $galleryTabWidth: 450px;
|
||||
}
|
||||
}
|
||||
|
||||
div.GalleryWall {
|
||||
@mixin galleryWidth($width) {
|
||||
height: math.div($width, 3) * 2;
|
||||
|
||||
&-landscape {
|
||||
width: $width;
|
||||
}
|
||||
|
||||
&-portrait {
|
||||
width: math.div($width, 2);
|
||||
}
|
||||
}
|
||||
|
||||
.GalleryWallCard {
|
||||
@media (min-width: 576px) {
|
||||
@include galleryWidth(96vw);
|
||||
}
|
||||
}
|
||||
|
||||
&.zoom-0 .GalleryWallCard {
|
||||
@media (min-width: 768px) {
|
||||
@include galleryWidth(16vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
@include galleryWidth(10vw);
|
||||
}
|
||||
}
|
||||
|
||||
&.zoom-1 .GalleryWallCard {
|
||||
@media (min-width: 768px) {
|
||||
@include galleryWidth(24vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
@include galleryWidth(16vw);
|
||||
}
|
||||
}
|
||||
|
||||
&.zoom-2 .GalleryWallCard {
|
||||
@media (min-width: 768px) {
|
||||
@include galleryWidth(32vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
@include galleryWidth(24vw);
|
||||
}
|
||||
}
|
||||
|
||||
&.zoom-3 .GalleryWallCard {
|
||||
@media (min-width: 768px) {
|
||||
@include galleryWidth(48vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
@include galleryWidth(32vw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-file-card.card {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
@@ -114,6 +114,7 @@ export const AddSubGroupsDialog: React.FC<IListOperationProps> = (
|
||||
onUpdate={(input) => setEntries(input)}
|
||||
excludeIDs={excludeIDs}
|
||||
filterHook={filterHook}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Form>
|
||||
</ModalComponent>
|
||||
|
||||
@@ -43,6 +43,7 @@ import { Button, Tab, Tabs } from "react-bootstrap";
|
||||
import { GroupSubGroupsPanel } from "./GroupSubGroupsPanel";
|
||||
import { GroupPerformersPanel } from "./GroupPerformersPanel";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { goBackOrReplace } from "src/utils/history";
|
||||
|
||||
const validTabs = ["default", "scenes", "performers", "subgroups"] as const;
|
||||
type TabKey = (typeof validTabs)[number];
|
||||
@@ -276,7 +277,7 @@ const GroupPage: React.FC<IProps> = ({ group, tabKey }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
history.goBack();
|
||||
goBackOrReplace(history, "/groups");
|
||||
}
|
||||
|
||||
function toggleEditing(value?: boolean) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { GroupList } from "../GroupList";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
@@ -101,6 +101,18 @@ interface IGroupSubGroupsPanel {
|
||||
group: GQL.GroupDataFragment;
|
||||
}
|
||||
|
||||
const defaultFilter = (() => {
|
||||
const sortBy = "sub_group_order";
|
||||
const ret = new ListFilterModel(GQL.FilterMode.Groups, undefined, {
|
||||
defaultSortBy: sortBy,
|
||||
});
|
||||
|
||||
// unset the sort by so that its not included in the URL
|
||||
ret.sortBy = undefined;
|
||||
|
||||
return ret;
|
||||
})();
|
||||
|
||||
export const GroupSubGroupsPanel: React.FC<IGroupSubGroupsPanel> = ({
|
||||
active,
|
||||
group,
|
||||
@@ -114,18 +126,6 @@ export const GroupSubGroupsPanel: React.FC<IGroupSubGroupsPanel> = ({
|
||||
|
||||
const filterHook = useContainingGroupFilterHook(group);
|
||||
|
||||
const defaultFilter = useMemo(() => {
|
||||
const sortBy = "sub_group_order";
|
||||
const ret = new ListFilterModel(GQL.FilterMode.Groups, undefined, {
|
||||
defaultSortBy: sortBy,
|
||||
});
|
||||
|
||||
// unset the sort by so that its not included in the URL
|
||||
ret.sortBy = undefined;
|
||||
|
||||
return ret;
|
||||
}, []);
|
||||
|
||||
async function removeSubGroups(
|
||||
result: GQL.FindGroupsQueryResult,
|
||||
filter: ListFilterModel,
|
||||
|
||||
@@ -34,6 +34,7 @@ import TextUtils from "src/utils/text";
|
||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
||||
import cx from "classnames";
|
||||
import { TruncatedText } from "src/components/Shared/TruncatedText";
|
||||
import { goBackOrReplace } from "src/utils/history";
|
||||
|
||||
interface IProps {
|
||||
image: GQL.ImageDataFragment;
|
||||
@@ -156,7 +157,7 @@ const ImagePage: React.FC<IProps> = ({ image }) => {
|
||||
function onDeleteDialogClosed(deleted: boolean) {
|
||||
setIsDeleteAlertOpen(false);
|
||||
if (deleted) {
|
||||
history.goBack();
|
||||
goBackOrReplace(history, "/images");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ImageWallItem } from "./ImageWallItem";
|
||||
import { EditImagesDialog } from "./EditImagesDialog";
|
||||
import { DeleteImagesDialog } from "./DeleteImagesDialog";
|
||||
import "flexbin/flexbin.css";
|
||||
import Gallery from "react-photo-gallery";
|
||||
import Gallery, { RenderImageProps } from "react-photo-gallery";
|
||||
import { ExportDialog } from "../Shared/ExportDialog";
|
||||
import { objectTitle } from "src/core/files";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
@@ -35,12 +35,27 @@ interface IImageWallProps {
|
||||
currentPage: number;
|
||||
pageCount: number;
|
||||
handleImageOpen: (index: number) => void;
|
||||
zoomIndex: number;
|
||||
}
|
||||
|
||||
const ImageWall: React.FC<IImageWallProps> = ({ images, handleImageOpen }) => {
|
||||
const zoomWidths = [280, 340, 480, 640];
|
||||
const breakpointZoomHeights = [
|
||||
{ minWidth: 576, heights: [100, 120, 240, 360] },
|
||||
{ minWidth: 768, heights: [120, 160, 240, 480] },
|
||||
{ minWidth: 1200, heights: [120, 160, 240, 300] },
|
||||
{ minWidth: 1400, heights: [160, 240, 300, 480] },
|
||||
];
|
||||
|
||||
const ImageWall: React.FC<IImageWallProps> = ({
|
||||
images,
|
||||
zoomIndex,
|
||||
handleImageOpen,
|
||||
}) => {
|
||||
const { configuration } = useContext(ConfigurationContext);
|
||||
const uiConfig = configuration?.ui;
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
let photos: {
|
||||
src: string;
|
||||
srcSet?: string | string[] | undefined;
|
||||
@@ -57,8 +72,8 @@ const ImageWall: React.FC<IImageWallProps> = ({ images, handleImageOpen }) => {
|
||||
image.paths.preview != ""
|
||||
? image.paths.preview!
|
||||
: image.paths.thumbnail!,
|
||||
width: image.visual_files[0].width,
|
||||
height: image.visual_files[0].height,
|
||||
width: image.visual_files?.[0]?.width ?? 0,
|
||||
height: image.visual_files?.[0]?.height ?? 0,
|
||||
tabIndex: index,
|
||||
key: image.id,
|
||||
loading: "lazy",
|
||||
@@ -76,21 +91,53 @@ const ImageWall: React.FC<IImageWallProps> = ({ images, handleImageOpen }) => {
|
||||
);
|
||||
|
||||
function columns(containerWidth: number) {
|
||||
let preferredSize = 300;
|
||||
let preferredSize = zoomWidths[zoomIndex];
|
||||
let columnCount = containerWidth / preferredSize;
|
||||
return Math.round(columnCount);
|
||||
}
|
||||
|
||||
const targetRowHeight = useCallback(
|
||||
(containerWidth: number) => {
|
||||
let zoomHeight = 280;
|
||||
breakpointZoomHeights.forEach((e) => {
|
||||
if (containerWidth >= e.minWidth) {
|
||||
zoomHeight = e.heights[zoomIndex];
|
||||
}
|
||||
});
|
||||
return zoomHeight;
|
||||
},
|
||||
[zoomIndex]
|
||||
);
|
||||
|
||||
// set the max height as a factor of the targetRowHeight
|
||||
// this allows some images to be taller than the target row height
|
||||
// but prevents images from becoming too tall when there is a small number of items
|
||||
const maxHeightFactor = 1.3;
|
||||
|
||||
const renderImage = useCallback(
|
||||
(props: RenderImageProps) => {
|
||||
// #6165 - only use targetRowHeight in row direction
|
||||
const maxHeight =
|
||||
props.direction === "column"
|
||||
? props.photo.height
|
||||
: targetRowHeight(containerRef.current?.offsetWidth ?? 0) *
|
||||
maxHeightFactor;
|
||||
return <ImageWallItem {...props} maxHeight={maxHeight} />;
|
||||
},
|
||||
[targetRowHeight]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="gallery">
|
||||
<div className="gallery" ref={containerRef}>
|
||||
{photos.length ? (
|
||||
<Gallery
|
||||
photos={photos}
|
||||
renderImage={ImageWallItem}
|
||||
renderImage={renderImage}
|
||||
onClick={showLightboxOnClick}
|
||||
margin={uiConfig?.imageWallOptions?.margin!}
|
||||
direction={uiConfig?.imageWallOptions?.direction!}
|
||||
columns={columns}
|
||||
targetRowHeight={targetRowHeight}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -211,6 +258,7 @@ const ImageListImages: React.FC<IImageListImages> = ({
|
||||
currentPage={filter.currentPage}
|
||||
pageCount={pageCount}
|
||||
handleImageOpen={handleImageOpen}
|
||||
zoomIndex={filter.zoomIndex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import React from "react";
|
||||
import type {
|
||||
RenderImageProps,
|
||||
renderImageClickHandler,
|
||||
PhotoProps,
|
||||
} from "react-photo-gallery";
|
||||
import type { RenderImageProps } from "react-photo-gallery";
|
||||
|
||||
interface IImageWallProps {
|
||||
margin?: string;
|
||||
index: number;
|
||||
photo: PhotoProps;
|
||||
onClick: renderImageClickHandler | null;
|
||||
direction: "row" | "column";
|
||||
top?: number;
|
||||
left?: number;
|
||||
interface IExtraProps {
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
export const ImageWallItem: React.FC<RenderImageProps> = (
|
||||
props: IImageWallProps
|
||||
export const ImageWallItem: React.FC<RenderImageProps & IExtraProps> = (
|
||||
props: RenderImageProps & IExtraProps
|
||||
) => {
|
||||
const height = Math.min(props.maxHeight, props.photo.height);
|
||||
const zoomFactor = height / props.photo.height;
|
||||
const width = props.photo.width * zoomFactor;
|
||||
|
||||
type style = Record<string, string | number | undefined>;
|
||||
var imgStyle: style = {
|
||||
margin: props.margin,
|
||||
@@ -49,8 +43,8 @@ export const ImageWallItem: React.FC<RenderImageProps> = (
|
||||
key={props.photo.key}
|
||||
style={imgStyle}
|
||||
src={props.photo.src}
|
||||
width={props.photo.width}
|
||||
height={props.photo.height}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={props.photo.alt}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
|
||||
@@ -29,11 +29,16 @@ import {
|
||||
import { useCompare, usePrevious } from "src/hooks/state";
|
||||
import { CriterionType } from "src/models/list-filter/types";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { useConfigureUI } from "src/core/StashService";
|
||||
import { FilterMode } from "src/core/generated-graphql";
|
||||
import { useConfigureUI, useSaveFilter } from "src/core/StashService";
|
||||
import {
|
||||
FilterMode,
|
||||
SavedFilterDataFragment,
|
||||
} from "src/core/generated-graphql";
|
||||
import { useFocusOnce } from "src/utils/focus";
|
||||
import Mousetrap from "mousetrap";
|
||||
import ScreenUtils from "src/utils/screen";
|
||||
import { LoadFilterDialog, SaveFilterDialog } from "./SavedFilterList";
|
||||
import { SearchTermInput } from "./ListFilter";
|
||||
|
||||
interface ICriterionList {
|
||||
criteria: string[];
|
||||
@@ -45,6 +50,7 @@ interface ICriterionList {
|
||||
optionSelected: (o?: CriterionOption) => void;
|
||||
onRemoveCriterion: (c: string) => void;
|
||||
onTogglePin: (c: CriterionOption) => void;
|
||||
externallySelected?: boolean;
|
||||
}
|
||||
|
||||
const CriterionOptionList: React.FC<ICriterionList> = ({
|
||||
@@ -57,6 +63,7 @@ const CriterionOptionList: React.FC<ICriterionList> = ({
|
||||
optionSelected,
|
||||
onRemoveCriterion,
|
||||
onTogglePin,
|
||||
externallySelected = false,
|
||||
}) => {
|
||||
const prevCriterion = usePrevious(currentCriterion);
|
||||
|
||||
@@ -96,14 +103,19 @@ const CriterionOptionList: React.FC<ICriterionList> = ({
|
||||
// scrolling to the current criterion doesn't work well when the
|
||||
// dialog is already open, so limit to when we click on the
|
||||
// criterion from the external tags
|
||||
if (!scrolled.current && type && criteriaRefs[type]?.current) {
|
||||
if (
|
||||
externallySelected &&
|
||||
!scrolled.current &&
|
||||
type &&
|
||||
criteriaRefs[type]?.current
|
||||
) {
|
||||
criteriaRefs[type].current!.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
scrolled.current = true;
|
||||
}
|
||||
}, [currentCriterion, criteriaRefs, type]);
|
||||
}, [externallySelected, currentCriterion, criteriaRefs, type]);
|
||||
|
||||
function getReleventCriterion(t: CriterionType) {
|
||||
if (currentCriterion?.criterionOption.type === t) {
|
||||
@@ -231,6 +243,13 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
|
||||
|
||||
const [searchRef, setSearchFocus] = useFocusOnce(!ScreenUtils.isTouch());
|
||||
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [savingFilter, setSavingFilter] = useState(false);
|
||||
|
||||
const [showLoadDialog, setShowLoadDialog] = useState(false);
|
||||
|
||||
const saveFilter = useSaveFilter();
|
||||
|
||||
const { criteria } = currentFilter;
|
||||
|
||||
const criteriaList = useMemo(() => {
|
||||
@@ -432,9 +451,74 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
|
||||
setCurrentFilter(newFilter);
|
||||
}
|
||||
|
||||
function onLoadFilter(f: SavedFilterDataFragment) {
|
||||
const newFilter = filter.clone();
|
||||
|
||||
newFilter.currentPage = 1;
|
||||
// #1795 - reset search term if not present in saved filter
|
||||
newFilter.searchTerm = "";
|
||||
newFilter.configureFromSavedFilter(f);
|
||||
// #1507 - reset random seed when loaded
|
||||
newFilter.randomSeed = -1;
|
||||
|
||||
onApply(newFilter);
|
||||
}
|
||||
|
||||
async function onSaveFilter(name: string, id?: string) {
|
||||
try {
|
||||
setSavingFilter(true);
|
||||
await saveFilter(filter, name, id);
|
||||
|
||||
Toast.success(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "toast.saved_entity",
|
||||
},
|
||||
{
|
||||
entity: intl.formatMessage({ id: "filter" }).toLocaleLowerCase(),
|
||||
}
|
||||
)
|
||||
);
|
||||
setShowSaveDialog(false);
|
||||
onApply(currentFilter);
|
||||
} catch (err) {
|
||||
Toast.error(err);
|
||||
} finally {
|
||||
setSavingFilter(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal show onHide={() => onCancel()} className="edit-filter-dialog">
|
||||
{showSaveDialog && (
|
||||
<SaveFilterDialog
|
||||
mode={filter.mode}
|
||||
onClose={(name, id) => {
|
||||
if (name) {
|
||||
onSaveFilter(name, id);
|
||||
} else {
|
||||
setShowSaveDialog(false);
|
||||
}
|
||||
}}
|
||||
isSaving={savingFilter}
|
||||
/>
|
||||
)}
|
||||
{showLoadDialog && (
|
||||
<LoadFilterDialog
|
||||
mode={filter.mode}
|
||||
onClose={(f) => {
|
||||
if (f) {
|
||||
onLoadFilter(f);
|
||||
}
|
||||
setShowLoadDialog(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Modal
|
||||
show={!showSaveDialog && !showLoadDialog}
|
||||
onHide={() => onCancel()}
|
||||
className="edit-filter-dialog"
|
||||
>
|
||||
<Modal.Header>
|
||||
<div>
|
||||
<FormattedMessage id="search_filter.edit_filter" />
|
||||
@@ -453,6 +537,15 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
|
||||
"criterion-selected": !!criterion,
|
||||
})}
|
||||
>
|
||||
<div className="search-term-row">
|
||||
<span>
|
||||
<FormattedMessage id="search_filter.search_term" />
|
||||
</span>
|
||||
<SearchTermInput
|
||||
filter={currentFilter}
|
||||
onFilterUpdate={setCurrentFilter}
|
||||
/>
|
||||
</div>
|
||||
<CriterionOptionList
|
||||
criteria={criteriaList}
|
||||
currentCriterion={criterion}
|
||||
@@ -463,6 +556,7 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
|
||||
selected={criterion?.criterionOption}
|
||||
onRemoveCriterion={(c) => removeCriterionString(c)}
|
||||
onTogglePin={(c) => onTogglePinFilter(c)}
|
||||
externallySelected={!!editingCriterion}
|
||||
/>
|
||||
{criteria.length > 0 && (
|
||||
<div>
|
||||
@@ -477,12 +571,30 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={() => onCancel()}>
|
||||
<FormattedMessage id="actions.cancel" />
|
||||
</Button>
|
||||
<Button onClick={() => onApply(currentFilter)}>
|
||||
<FormattedMessage id="actions.apply" />
|
||||
</Button>
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowLoadDialog(true)}
|
||||
title={intl.formatMessage({ id: "actions.load_filter" })}
|
||||
>
|
||||
<FormattedMessage id="actions.load" />…
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowSaveDialog(true)}
|
||||
title={intl.formatMessage({ id: "actions.save_filter" })}
|
||||
>
|
||||
<FormattedMessage id="actions.save" />…
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="secondary" onClick={() => onCancel()}>
|
||||
<FormattedMessage id="actions.cancel" />
|
||||
</Button>
|
||||
<Button onClick={() => onApply(currentFilter)}>
|
||||
<FormattedMessage id="actions.apply" />
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -1,32 +1,45 @@
|
||||
import React, { PropsWithChildren } from "react";
|
||||
import { Badge, BadgeProps, Button } from "react-bootstrap";
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Badge, BadgeProps, Button, Overlay, Popover } from "react-bootstrap";
|
||||
import { Criterion } from "src/models/list-filter/criteria/criterion";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faMagnifyingGlass, faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
import { BsPrefixProps, ReplaceProps } from "react-bootstrap/esm/helpers";
|
||||
import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields";
|
||||
import { useDebounce } from "src/hooks/debounce";
|
||||
import cx from "classnames";
|
||||
|
||||
type TagItemProps = PropsWithChildren<
|
||||
ReplaceProps<"span", BsPrefixProps<"span"> & BadgeProps>
|
||||
>;
|
||||
|
||||
export const TagItem: React.FC<TagItemProps> = (props) => {
|
||||
const { children } = props;
|
||||
const { className, children, ...others } = props;
|
||||
return (
|
||||
<Badge className="tag-item" variant="secondary" {...props}>
|
||||
<Badge
|
||||
className={cx("tag-item", className)}
|
||||
variant="secondary"
|
||||
{...others}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const FilterTag: React.FC<{
|
||||
className?: string;
|
||||
label: React.ReactNode;
|
||||
onClick: React.MouseEventHandler<HTMLSpanElement>;
|
||||
onRemove: React.MouseEventHandler<HTMLElement>;
|
||||
}> = ({ label, onClick, onRemove }) => {
|
||||
}> = ({ className, label, onClick, onRemove }) => {
|
||||
return (
|
||||
<TagItem onClick={onClick}>
|
||||
<TagItem className={className} onClick={onClick}>
|
||||
{label}
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -41,20 +54,183 @@ export const FilterTag: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const MoreFilterTags: React.FC<{
|
||||
tags: React.ReactNode[];
|
||||
}> = ({ tags }) => {
|
||||
const [showTooltip, setShowTooltip] = React.useState(false);
|
||||
const target = useRef(null);
|
||||
|
||||
if (!tags.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
setShowTooltip(true);
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
setShowTooltip(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Overlay target={target.current} placement="bottom" show={showTooltip}>
|
||||
<Popover
|
||||
id="more-criteria-popover"
|
||||
className="hover-popover-content"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleMouseLeave}
|
||||
>
|
||||
{tags}
|
||||
</Popover>
|
||||
</Overlay>
|
||||
<Badge
|
||||
ref={target}
|
||||
className={"tag-item more-tags"}
|
||||
variant="secondary"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="search_filter.more_filter_criteria"
|
||||
values={{ count: tags.length }}
|
||||
/>
|
||||
</Badge>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IFilterTagsProps {
|
||||
searchTerm?: string;
|
||||
criteria: Criterion[];
|
||||
onEditSearchTerm?: () => void;
|
||||
onEditCriterion: (c: Criterion) => void;
|
||||
onRemoveCriterion: (c: Criterion, valueIndex?: number) => void;
|
||||
onRemoveAll: () => void;
|
||||
onRemoveSearchTerm?: () => void;
|
||||
truncateOnOverflow?: boolean;
|
||||
}
|
||||
|
||||
export const FilterTags: React.FC<IFilterTagsProps> = ({
|
||||
searchTerm,
|
||||
criteria,
|
||||
onEditCriterion,
|
||||
onRemoveCriterion,
|
||||
onRemoveAll,
|
||||
onEditSearchTerm,
|
||||
onRemoveSearchTerm,
|
||||
truncateOnOverflow = false,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [cutoff, setCutoff] = React.useState<number | undefined>();
|
||||
const elementGap = 10; // Adjust this value based on your CSS gap or margin
|
||||
const moreTagWidth = 80; // reserve space for the "more" tag
|
||||
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
const debounceResetCutoff = useDebounce(
|
||||
() => {
|
||||
setCutoff(undefined);
|
||||
// setting cutoff won't trigger a re-render if it's already undefined
|
||||
// so we force a re-render to recalculate the cutoff
|
||||
forceUpdate();
|
||||
},
|
||||
100 // Adjust the debounce delay as needed
|
||||
);
|
||||
|
||||
// trigger recalculation of cutoff when control resizes
|
||||
useEffect(() => {
|
||||
if (!truncateOnOverflow || !ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
debounceResetCutoff();
|
||||
});
|
||||
|
||||
const { current } = ref;
|
||||
resizeObserver.observe(current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [truncateOnOverflow, debounceResetCutoff]);
|
||||
|
||||
// we need to check this on every render, and the call to setCutoff _should_ be safe
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
useLayoutEffect(() => {
|
||||
if (!truncateOnOverflow) {
|
||||
setCutoff(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const { current } = ref;
|
||||
|
||||
if (current) {
|
||||
// calculate the number of tags that can fit in the container
|
||||
const containerWidth = current.clientWidth;
|
||||
const children = Array.from(current.children);
|
||||
|
||||
// don't recalculate anything if the more tag is visible and cutoff is already set
|
||||
const moreTags = children.find((child) => {
|
||||
return (child as HTMLElement).classList.contains("more-tags");
|
||||
});
|
||||
|
||||
if (moreTags && cutoff !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const childTags = children.filter((child) => {
|
||||
return (
|
||||
(child as HTMLElement).classList.contains("tag-item") ||
|
||||
(child as HTMLElement).classList.contains("clear-all-button")
|
||||
);
|
||||
});
|
||||
|
||||
const clearAllButton = children.find((child) => {
|
||||
return (child as HTMLElement).classList.contains("clear-all-button");
|
||||
});
|
||||
|
||||
// calculate the total width without the more tag
|
||||
const defaultTotalWidth = childTags.reduce((total, child, idx) => {
|
||||
return (
|
||||
total +
|
||||
((child as HTMLElement).offsetWidth ?? 0) +
|
||||
(idx === childTags.length - 1 ? 0 : elementGap)
|
||||
);
|
||||
}, 0);
|
||||
|
||||
if (containerWidth >= defaultTotalWidth) {
|
||||
// if the container is wide enough to fit all tags, reset cutoff
|
||||
setCutoff(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
let totalWidth = 0;
|
||||
let visibleCount = 0;
|
||||
|
||||
// reserve space for the more tags control
|
||||
totalWidth += moreTagWidth;
|
||||
|
||||
// reserve space for the clear all button if present
|
||||
if (clearAllButton) {
|
||||
totalWidth += (clearAllButton as HTMLElement).offsetWidth ?? 0;
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
totalWidth += ((child as HTMLElement).offsetWidth ?? 0) + elementGap;
|
||||
if (totalWidth > containerWidth) {
|
||||
break;
|
||||
}
|
||||
visibleCount++;
|
||||
}
|
||||
|
||||
setCutoff(visibleCount);
|
||||
}
|
||||
});
|
||||
|
||||
function onRemoveCriterionTag(
|
||||
criterion: Criterion,
|
||||
@@ -72,7 +248,7 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
||||
onEditCriterion(criterion);
|
||||
}
|
||||
|
||||
function renderFilterTags(criterion: Criterion) {
|
||||
function getFilterTags(criterion: Criterion) {
|
||||
if (
|
||||
criterion instanceof CustomFieldsCriterion &&
|
||||
criterion.value.length > 1
|
||||
@@ -101,10 +277,40 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (criteria.length === 0 && !searchTerm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const className = "wrap-tags filter-tags";
|
||||
|
||||
const filterTags = criteria.map((c) => getFilterTags(c)).flat();
|
||||
|
||||
if (searchTerm && searchTerm.length > 0) {
|
||||
filterTags.unshift(
|
||||
<FilterTag
|
||||
key="search-term"
|
||||
className="search-term-filter-tag"
|
||||
label={
|
||||
<span className="search-term">
|
||||
<Icon icon={faMagnifyingGlass} />
|
||||
{searchTerm}
|
||||
</span>
|
||||
}
|
||||
onClick={() => onEditSearchTerm?.()}
|
||||
onRemove={() => onRemoveSearchTerm?.()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleCriteria =
|
||||
cutoff !== undefined ? filterTags.slice(0, cutoff) : filterTags;
|
||||
const hiddenCriteria = cutoff !== undefined ? filterTags.slice(cutoff) : [];
|
||||
|
||||
return (
|
||||
<div className="d-flex justify-content-center mb-2 wrap-tags filter-tags">
|
||||
{criteria.map(renderFilterTags)}
|
||||
{criteria.length >= 3 && (
|
||||
<div className={className} ref={ref}>
|
||||
{visibleCriteria}
|
||||
<MoreFilterTags tags={hiddenCriteria} />
|
||||
{filterTags.length >= 3 && (
|
||||
<Button
|
||||
variant="minimal"
|
||||
className="clear-all-button"
|
||||
|
||||
@@ -8,11 +8,9 @@ import {
|
||||
IListFilterOperation,
|
||||
ListOperationButtons,
|
||||
} from "./ListOperationButtons";
|
||||
import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap";
|
||||
import { ButtonGroup, ButtonToolbar } from "react-bootstrap";
|
||||
import { View } from "./views";
|
||||
import { IListSelect, useFilterOperations } from "./util";
|
||||
import { SidebarIcon } from "../Shared/Sidebar";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
export interface IItemListOperation<T extends QueryResult> {
|
||||
text: string;
|
||||
@@ -43,7 +41,6 @@ export interface IFilteredListToolbar {
|
||||
onDelete?: () => void;
|
||||
operations?: IListFilterOperation[];
|
||||
zoomable?: boolean;
|
||||
onToggleSidebar?: () => void;
|
||||
}
|
||||
|
||||
export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
||||
@@ -56,9 +53,7 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
||||
onDelete,
|
||||
operations,
|
||||
zoomable = false,
|
||||
onToggleSidebar,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const filterOptions = filter.options;
|
||||
const { setDisplayMode, setZoom } = useFilterOperations({
|
||||
filter,
|
||||
@@ -68,21 +63,6 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
||||
|
||||
return (
|
||||
<ButtonToolbar className="filtered-list-toolbar">
|
||||
<ButtonGroup>
|
||||
{onToggleSidebar && (
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
className="sidebar-toggle-button"
|
||||
onClick={onToggleSidebar}
|
||||
variant="secondary"
|
||||
title={intl.formatMessage({ id: "actions.sidebar.open" })}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup>
|
||||
{showEditFilter && (
|
||||
<ListFilter
|
||||
@@ -90,7 +70,6 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
||||
filter={filter}
|
||||
openFilterDialog={() => showEditFilter()}
|
||||
view={view}
|
||||
withSidebar={!!onToggleSidebar}
|
||||
/>
|
||||
)}
|
||||
<ListOperationButtons
|
||||
|
||||
@@ -54,6 +54,7 @@ interface ISidebarFilter {
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
sectionID?: string;
|
||||
}
|
||||
|
||||
export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
|
||||
@@ -61,6 +62,7 @@ export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
|
||||
option,
|
||||
filter,
|
||||
setFilter,
|
||||
sectionID,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -127,6 +129,7 @@ export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
|
||||
onUnselect={onUnselect}
|
||||
selected={selected}
|
||||
singleValue
|
||||
sectionID={sectionID}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React from "react";
|
||||
import { Badge, Button } from "react-bootstrap";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { faFilter } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
interface IFilterButtonProps {
|
||||
filter: ListFilterModel;
|
||||
count?: number;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const FilterButton: React.FC<IFilterButtonProps> = ({
|
||||
filter,
|
||||
count = 0,
|
||||
onClick,
|
||||
title,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const count = useMemo(() => filter.count(), [filter]);
|
||||
|
||||
if (!title) {
|
||||
title = intl.formatMessage({ id: "search_filter.edit_filter" });
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="filter-button"
|
||||
onClick={onClick}
|
||||
title={intl.formatMessage({ id: "search_filter.edit_filter" })}
|
||||
title={title}
|
||||
>
|
||||
<Icon icon={faFilter} />
|
||||
{count ? (
|
||||
|
||||
@@ -1,54 +1,68 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { SidebarSection, SidebarToolbar } from "src/components/Shared/Sidebar";
|
||||
import { SidebarSection } from "src/components/Shared/Sidebar";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { FilterButton } from "./FilterButton";
|
||||
import { SearchTermInput } from "../ListFilter";
|
||||
import { SidebarSavedFilterList } from "../SavedFilterList";
|
||||
import { View } from "../views";
|
||||
import useFocus from "src/utils/focus";
|
||||
import ScreenUtils from "src/utils/screen";
|
||||
import Mousetrap from "mousetrap";
|
||||
import { Button } from "react-bootstrap";
|
||||
|
||||
export const FilteredSidebarToolbar: React.FC<{
|
||||
onClose?: () => void;
|
||||
}> = ({ onClose, children }) => {
|
||||
return <SidebarToolbar onClose={onClose}>{children}</SidebarToolbar>;
|
||||
};
|
||||
const savedFiltersSectionID = "saved-filters";
|
||||
|
||||
export const FilteredSidebarHeader: React.FC<{
|
||||
sidebarOpen: boolean;
|
||||
onClose?: () => void;
|
||||
showEditFilter: () => void;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (filter: ListFilterModel) => void;
|
||||
view?: View;
|
||||
}> = ({ sidebarOpen, onClose, showEditFilter, filter, setFilter, view }) => {
|
||||
const focus = useFocus();
|
||||
focus?: ReturnType<typeof useFocus>;
|
||||
}> = ({
|
||||
sidebarOpen,
|
||||
showEditFilter,
|
||||
filter,
|
||||
setFilter,
|
||||
view,
|
||||
focus: providedFocus,
|
||||
}) => {
|
||||
const localFocus = useFocus();
|
||||
const focus = providedFocus ?? localFocus;
|
||||
const [, setFocus] = focus;
|
||||
|
||||
// Set the focus on the input field when the sidebar is opened
|
||||
// Don't do this on mobile devices
|
||||
// Don't do this on touch devices
|
||||
useEffect(() => {
|
||||
if (sidebarOpen && !ScreenUtils.isMobile()) {
|
||||
if (sidebarOpen && !ScreenUtils.isTouch()) {
|
||||
setFocus();
|
||||
}
|
||||
}, [sidebarOpen, setFocus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilteredSidebarToolbar onClose={onClose} />
|
||||
<div className="sidebar-search-container">
|
||||
<SearchTermInput
|
||||
filter={filter}
|
||||
onFilterUpdate={setFilter}
|
||||
focus={focus}
|
||||
/>
|
||||
<FilterButton onClick={() => showEditFilter()} filter={filter} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
className="edit-filter-button"
|
||||
size="sm"
|
||||
onClick={() => showEditFilter()}
|
||||
>
|
||||
<FormattedMessage id="search_filter.edit_filter" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SidebarSection
|
||||
className="sidebar-saved-filters"
|
||||
text={<FormattedMessage id="search_filter.saved_filters" />}
|
||||
sectionID={savedFiltersSectionID}
|
||||
>
|
||||
<SidebarSavedFilterList
|
||||
filter={filter}
|
||||
@@ -66,20 +80,6 @@ export function useFilteredSidebarKeybinds(props: {
|
||||
}) {
|
||||
const { showSidebar, setShowSidebar } = props;
|
||||
|
||||
// Show the sidebar when the user presses the "/" key
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("/", (e) => {
|
||||
if (!showSidebar) {
|
||||
setShowSidebar(true);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("/");
|
||||
};
|
||||
}, [showSidebar, setShowSidebar]);
|
||||
|
||||
// Hide the sidebar when the user presses the "Esc" key
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("esc", (e) => {
|
||||
|
||||
@@ -321,18 +321,24 @@ export function useCriterion(
|
||||
return { criterion, setCriterion };
|
||||
}
|
||||
|
||||
export interface IUseQueryHookProps {
|
||||
q: string;
|
||||
filter?: ListFilterModel;
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
skip: boolean;
|
||||
}
|
||||
|
||||
export function useQueryState(
|
||||
useQuery: (
|
||||
q: string,
|
||||
filter: ListFilterModel,
|
||||
skip: boolean
|
||||
) => ILoadResults<ILabeledId[]>,
|
||||
useQuery: (props: IUseQueryHookProps) => ILoadResults<ILabeledId[]>,
|
||||
filter: ListFilterModel,
|
||||
skip: boolean
|
||||
skip: boolean,
|
||||
options?: {
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
}
|
||||
) {
|
||||
const [query, setQuery] = useState("");
|
||||
const { results: queryResults } = useCacheResults(
|
||||
useQuery(query, filter, skip)
|
||||
useQuery({ q: query, filter, filterHook: options?.filterHook, skip })
|
||||
);
|
||||
|
||||
return { query, setQuery, queryResults };
|
||||
@@ -431,11 +437,8 @@ export function useLabeledIdFilterState(props: {
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
useQuery: (
|
||||
q: string,
|
||||
filter: ListFilterModel,
|
||||
skip: boolean
|
||||
) => ILoadResults<ILabeledId[]>;
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
useQuery: (props: IUseQueryHookProps) => ILoadResults<ILabeledId[]>;
|
||||
singleValue?: boolean;
|
||||
hierarchical?: boolean;
|
||||
includeSubMessageID?: string;
|
||||
@@ -444,6 +447,7 @@ export function useLabeledIdFilterState(props: {
|
||||
option,
|
||||
filter,
|
||||
setFilter,
|
||||
filterHook,
|
||||
useQuery,
|
||||
singleValue = false,
|
||||
hierarchical = false,
|
||||
@@ -456,7 +460,8 @@ export function useLabeledIdFilterState(props: {
|
||||
const { query, setQuery, queryResults } = useQueryState(
|
||||
useQuery,
|
||||
filter,
|
||||
skip
|
||||
skip,
|
||||
{ filterHook }
|
||||
);
|
||||
|
||||
const { criterion, setCriterion } = useCriterion(option, filter, setFilter);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { sortByRelevance } from "src/utils/query";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { CriterionOption } from "src/models/list-filter/criteria/criterion";
|
||||
import {
|
||||
IUseQueryHookProps,
|
||||
makeQueryVariables,
|
||||
setObjectFilter,
|
||||
useLabeledIdFilterState,
|
||||
@@ -69,13 +70,12 @@ function sortResults(
|
||||
});
|
||||
}
|
||||
|
||||
function usePerformerQueryFilter(
|
||||
query: string,
|
||||
f?: ListFilterModel,
|
||||
skip?: boolean
|
||||
) {
|
||||
function usePerformerQueryFilter(props: IUseQueryHookProps) {
|
||||
const { q: query, filter: f, skip, filterHook } = props;
|
||||
const appliedFilter = filterHook && f ? filterHook(f.clone()) : f;
|
||||
|
||||
const { data, loading } = useFindPerformersForSelectQuery({
|
||||
variables: queryVariables(query, f),
|
||||
variables: queryVariables(query, appliedFilter),
|
||||
skip,
|
||||
});
|
||||
|
||||
@@ -88,7 +88,7 @@ function usePerformerQueryFilter(
|
||||
}
|
||||
|
||||
function usePerformerQuery(query: string, skip?: boolean) {
|
||||
return usePerformerQueryFilter(query, undefined, skip);
|
||||
return usePerformerQueryFilter({ q: query, skip: !!skip });
|
||||
}
|
||||
|
||||
const PerformersFilter: React.FC<IPerformersFilter> = ({
|
||||
@@ -109,15 +109,18 @@ export const SidebarPerformersFilter: React.FC<{
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
}> = ({ title, option, filter, setFilter }) => {
|
||||
filterHook?: (f: ListFilterModel) => ListFilterModel;
|
||||
sectionID?: string;
|
||||
}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
|
||||
const state = useLabeledIdFilterState({
|
||||
filter,
|
||||
setFilter,
|
||||
filterHook,
|
||||
option,
|
||||
useQuery: usePerformerQueryFilter,
|
||||
});
|
||||
|
||||
return <SidebarListFilter {...state} title={title} />;
|
||||
return <SidebarListFilter {...state} title={title} sectionID={sectionID} />;
|
||||
};
|
||||
|
||||
export default PerformersFilter;
|
||||
|
||||
@@ -77,6 +77,7 @@ interface ISidebarFilter {
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
sectionID?: string;
|
||||
}
|
||||
|
||||
const any = "any";
|
||||
@@ -87,6 +88,7 @@ export const SidebarRatingFilter: React.FC<ISidebarFilter> = ({
|
||||
option,
|
||||
filter,
|
||||
setFilter,
|
||||
sectionID,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -199,6 +201,7 @@ export const SidebarRatingFilter: React.FC<ISidebarFilter> = ({
|
||||
singleValue
|
||||
preCandidates={ratingValue === null ? ratingStars : undefined}
|
||||
preSelected={ratingValue !== null ? ratingStars : undefined}
|
||||
sectionID={sectionID}
|
||||
/>
|
||||
<div></div>
|
||||
</>
|
||||
|
||||
@@ -276,6 +276,8 @@ export const SidebarListFilter: React.FC<{
|
||||
preCandidates?: React.ReactNode;
|
||||
postCandidates?: React.ReactNode;
|
||||
onOpen?: () => void;
|
||||
// used to store open/closed state in SidebarStateContext
|
||||
sectionID?: string;
|
||||
}> = ({
|
||||
title,
|
||||
selected,
|
||||
@@ -292,6 +294,7 @@ export const SidebarListFilter: React.FC<{
|
||||
preSelected,
|
||||
postSelected,
|
||||
onOpen,
|
||||
sectionID,
|
||||
}) => {
|
||||
// TODO - sort items?
|
||||
|
||||
@@ -325,6 +328,7 @@ export const SidebarListFilter: React.FC<{
|
||||
<SidebarSection
|
||||
className="sidebar-list-filter"
|
||||
text={title}
|
||||
sectionID={sectionID}
|
||||
outsideCollapse={
|
||||
<>
|
||||
{preSelected ? <div className="extra">{preSelected}</div> : null}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { sortByRelevance } from "src/utils/query";
|
||||
import { CriterionOption } from "src/models/list-filter/criteria/criterion";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import {
|
||||
IUseQueryHookProps,
|
||||
makeQueryVariables,
|
||||
setObjectFilter,
|
||||
useLabeledIdFilterState,
|
||||
@@ -56,13 +57,12 @@ function sortResults(
|
||||
});
|
||||
}
|
||||
|
||||
function useStudioQueryFilter(
|
||||
query: string,
|
||||
filter?: ListFilterModel,
|
||||
skip?: boolean
|
||||
) {
|
||||
function useStudioQueryFilter(props: IUseQueryHookProps) {
|
||||
const { q: query, filter: f, skip, filterHook } = props;
|
||||
const appliedFilter = filterHook && f ? filterHook(f.clone()) : f;
|
||||
|
||||
const { data, loading } = useFindStudiosForSelectQuery({
|
||||
variables: queryVariables(query, filter),
|
||||
variables: queryVariables(query, appliedFilter),
|
||||
skip,
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ function useStudioQueryFilter(
|
||||
}
|
||||
|
||||
function useStudioQuery(query: string, skip?: boolean) {
|
||||
return useStudioQueryFilter(query, undefined, skip);
|
||||
return useStudioQueryFilter({ q: query, skip: !!skip });
|
||||
}
|
||||
|
||||
const StudiosFilter: React.FC<IStudiosFilter> = ({
|
||||
@@ -97,10 +97,13 @@ export const SidebarStudiosFilter: React.FC<{
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
}> = ({ title, option, filter, setFilter }) => {
|
||||
filterHook?: (f: ListFilterModel) => ListFilterModel;
|
||||
sectionID?: string;
|
||||
}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
|
||||
const state = useLabeledIdFilterState({
|
||||
filter,
|
||||
setFilter,
|
||||
filterHook,
|
||||
option,
|
||||
useQuery: useStudioQueryFilter,
|
||||
singleValue: true,
|
||||
@@ -108,7 +111,7 @@ export const SidebarStudiosFilter: React.FC<{
|
||||
includeSubMessageID: "subsidiary_studios",
|
||||
});
|
||||
|
||||
return <SidebarListFilter {...state} title={title} />;
|
||||
return <SidebarListFilter {...state} title={title} sectionID={sectionID} />;
|
||||
};
|
||||
|
||||
export default StudiosFilter;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { sortByRelevance } from "src/utils/query";
|
||||
import { CriterionOption } from "src/models/list-filter/criteria/criterion";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import {
|
||||
IUseQueryHookProps,
|
||||
makeQueryVariables,
|
||||
setObjectFilter,
|
||||
useLabeledIdFilterState,
|
||||
@@ -65,13 +66,12 @@ function sortResults(
|
||||
});
|
||||
}
|
||||
|
||||
function useTagQueryFilter(
|
||||
query: string,
|
||||
filter?: ListFilterModel,
|
||||
skip?: boolean
|
||||
) {
|
||||
function useTagQueryFilter(props: IUseQueryHookProps) {
|
||||
const { q: query, filter: f, skip, filterHook } = props;
|
||||
const appliedFilter = filterHook && f ? filterHook(f.clone()) : f;
|
||||
|
||||
const { data, loading } = useFindTagsForSelectQuery({
|
||||
variables: queryVariables(query, filter),
|
||||
variables: queryVariables(query, appliedFilter),
|
||||
skip,
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ function useTagQueryFilter(
|
||||
}
|
||||
|
||||
function useTagQuery(query: string, skip?: boolean) {
|
||||
return useTagQueryFilter(query, undefined, skip);
|
||||
return useTagQueryFilter({ q: query, skip: !!skip });
|
||||
}
|
||||
|
||||
const TagsFilter: React.FC<ITagsFilter> = ({ criterion, setCriterion }) => {
|
||||
@@ -102,17 +102,20 @@ export const SidebarTagsFilter: React.FC<{
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
}> = ({ title, option, filter, setFilter }) => {
|
||||
filterHook?: (f: ListFilterModel) => ListFilterModel;
|
||||
sectionID?: string;
|
||||
}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
|
||||
const state = useLabeledIdFilterState({
|
||||
filter,
|
||||
setFilter,
|
||||
filterHook,
|
||||
option,
|
||||
useQuery: useTagQueryFilter,
|
||||
hierarchical: true,
|
||||
includeSubMessageID: "sub_tags",
|
||||
});
|
||||
|
||||
return <SidebarListFilter {...state} title={title} />;
|
||||
return <SidebarListFilter {...state} title={title} sectionID={sectionID} />;
|
||||
};
|
||||
|
||||
export default TagsFilter;
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useDebounce } from "src/hooks/debounce";
|
||||
import { View } from "./views";
|
||||
import { ClearableInput } from "../Shared/ClearableInput";
|
||||
import { useStopWheelScroll } from "src/utils/form";
|
||||
import { ISortByOption } from "src/models/list-filter/filter-options";
|
||||
|
||||
export function useDebouncedSearchInput(
|
||||
filter: ListFilterModel,
|
||||
@@ -230,12 +231,99 @@ export const PageSizeSelector: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const SortBySelect: React.FC<{
|
||||
className?: string;
|
||||
sortBy: string | undefined;
|
||||
sortDirection: SortDirectionEnum;
|
||||
options: ISortByOption[];
|
||||
onChangeSortBy: (eventKey: string | null) => void;
|
||||
onChangeSortDirection: () => void;
|
||||
onReshuffleRandomSort: () => void;
|
||||
}> = ({
|
||||
className,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
options,
|
||||
onChangeSortBy,
|
||||
onChangeSortDirection,
|
||||
onReshuffleRandomSort,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const currentSortBy = options.find((o) => o.value === sortBy);
|
||||
|
||||
function renderSortByOptions() {
|
||||
return options
|
||||
.map((o) => {
|
||||
return {
|
||||
message: intl.formatMessage({ id: o.messageID }),
|
||||
value: o.value,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.message.localeCompare(b.message))
|
||||
.map((option) => (
|
||||
<Dropdown.Item
|
||||
onSelect={onChangeSortBy}
|
||||
key={option.value}
|
||||
className="bg-secondary text-white"
|
||||
eventKey={option.value}
|
||||
>
|
||||
{option.message}
|
||||
</Dropdown.Item>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown as={ButtonGroup} className={className}>
|
||||
<InputGroup.Prepend>
|
||||
<Dropdown.Toggle variant="secondary">
|
||||
{currentSortBy
|
||||
? intl.formatMessage({ id: currentSortBy.messageID })
|
||||
: ""}
|
||||
</Dropdown.Toggle>
|
||||
</InputGroup.Prepend>
|
||||
<Dropdown.Menu className="bg-secondary text-white">
|
||||
{renderSortByOptions()}
|
||||
</Dropdown.Menu>
|
||||
<OverlayTrigger
|
||||
overlay={
|
||||
<Tooltip id="sort-direction-tooltip">
|
||||
{sortDirection === SortDirectionEnum.Asc
|
||||
? intl.formatMessage({ id: "ascending" })
|
||||
: intl.formatMessage({ id: "descending" })}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button variant="secondary" onClick={onChangeSortDirection}>
|
||||
<Icon
|
||||
icon={
|
||||
sortDirection === SortDirectionEnum.Asc ? faCaretUp : faCaretDown
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
{sortBy === "random" && (
|
||||
<OverlayTrigger
|
||||
overlay={
|
||||
<Tooltip id="sort-reshuffle-tooltip">
|
||||
{intl.formatMessage({ id: "actions.reshuffle" })}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button variant="secondary" onClick={onReshuffleRandomSort}>
|
||||
<Icon icon={faRandom} />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
interface IListFilterProps {
|
||||
onFilterUpdate: (newFilter: ListFilterModel) => void;
|
||||
filter: ListFilterModel;
|
||||
view?: View;
|
||||
openFilterDialog: () => void;
|
||||
withSidebar?: boolean;
|
||||
}
|
||||
|
||||
export const ListFilter: React.FC<IListFilterProps> = ({
|
||||
@@ -243,12 +331,9 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
||||
filter,
|
||||
openFilterDialog,
|
||||
view,
|
||||
withSidebar,
|
||||
}) => {
|
||||
const filterOptions = filter.options;
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("r", () => onReshuffleRandomSort());
|
||||
|
||||
@@ -289,109 +374,45 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
||||
onFilterUpdate(newFilter);
|
||||
}
|
||||
|
||||
function renderSortByOptions() {
|
||||
return filterOptions.sortByOptions
|
||||
.map((o) => {
|
||||
return {
|
||||
message: intl.formatMessage({ id: o.messageID }),
|
||||
value: o.value,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.message.localeCompare(b.message))
|
||||
.map((option) => (
|
||||
<Dropdown.Item
|
||||
onSelect={onChangeSortBy}
|
||||
key={option.value}
|
||||
className="bg-secondary text-white"
|
||||
eventKey={option.value}
|
||||
>
|
||||
{option.message}
|
||||
</Dropdown.Item>
|
||||
));
|
||||
}
|
||||
|
||||
function render() {
|
||||
const currentSortBy = filterOptions.sortByOptions.find(
|
||||
(o) => o.value === filter.sortBy
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!withSidebar && (
|
||||
<div className="d-flex">
|
||||
<SearchTermInput filter={filter} onFilterUpdate={onFilterUpdate} />
|
||||
</div>
|
||||
)}
|
||||
<div className="d-flex">
|
||||
<SearchTermInput filter={filter} onFilterUpdate={onFilterUpdate} />
|
||||
</div>
|
||||
|
||||
{!withSidebar && (
|
||||
<ButtonGroup className="mr-2">
|
||||
<SavedFilterDropdown
|
||||
filter={filter}
|
||||
onSetFilter={(f) => {
|
||||
onFilterUpdate(f);
|
||||
}}
|
||||
view={view}
|
||||
/>
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={
|
||||
<Tooltip id="filter-tooltip">
|
||||
<FormattedMessage id="search_filter.name" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<FilterButton
|
||||
onClick={() => openFilterDialog()}
|
||||
filter={filter}
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
|
||||
<Dropdown as={ButtonGroup} className="mr-2">
|
||||
<InputGroup.Prepend>
|
||||
<Dropdown.Toggle variant="secondary">
|
||||
{currentSortBy
|
||||
? intl.formatMessage({ id: currentSortBy.messageID })
|
||||
: ""}
|
||||
</Dropdown.Toggle>
|
||||
</InputGroup.Prepend>
|
||||
<Dropdown.Menu className="bg-secondary text-white">
|
||||
{renderSortByOptions()}
|
||||
</Dropdown.Menu>
|
||||
<ButtonGroup className="mr-2">
|
||||
<SavedFilterDropdown
|
||||
filter={filter}
|
||||
onSetFilter={(f) => {
|
||||
onFilterUpdate(f);
|
||||
}}
|
||||
view={view}
|
||||
/>
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={
|
||||
<Tooltip id="sort-direction-tooltip">
|
||||
{filter.sortDirection === SortDirectionEnum.Asc
|
||||
? intl.formatMessage({ id: "ascending" })
|
||||
: intl.formatMessage({ id: "descending" })}
|
||||
<Tooltip id="filter-tooltip">
|
||||
<FormattedMessage id="search_filter.name" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button variant="secondary" onClick={onChangeSortDirection}>
|
||||
<Icon
|
||||
icon={
|
||||
filter.sortDirection === SortDirectionEnum.Asc
|
||||
? faCaretUp
|
||||
: faCaretDown
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
<FilterButton
|
||||
onClick={() => openFilterDialog()}
|
||||
count={filter.count()}
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
{filter.sortBy === "random" && (
|
||||
<OverlayTrigger
|
||||
overlay={
|
||||
<Tooltip id="sort-reshuffle-tooltip">
|
||||
{intl.formatMessage({ id: "actions.reshuffle" })}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button variant="secondary" onClick={onReshuffleRandomSort}>
|
||||
<Icon icon={faRandom} />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</Dropdown>
|
||||
</ButtonGroup>
|
||||
|
||||
<SortBySelect
|
||||
className="mr-2"
|
||||
sortBy={filter.sortBy}
|
||||
sortDirection={filter.sortDirection}
|
||||
options={filterOptions.sortByOptions}
|
||||
onChangeSortBy={onChangeSortBy}
|
||||
onChangeSortDirection={onChangeSortDirection}
|
||||
onReshuffleRandomSort={onReshuffleRandomSort}
|
||||
/>
|
||||
|
||||
<PageSizeSelector
|
||||
pageSize={filter.itemsPerPage}
|
||||
|
||||
@@ -15,24 +15,48 @@ import {
|
||||
faPencilAlt,
|
||||
faTrash,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import cx from "classnames";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export const OperationDropdown: React.FC<PropsWithChildren<{}>> = ({
|
||||
children,
|
||||
}) => {
|
||||
export const OperationDropdown: React.FC<
|
||||
PropsWithChildren<{
|
||||
className?: string;
|
||||
menuPortalTarget?: HTMLElement;
|
||||
}>
|
||||
> = ({ className, menuPortalTarget, children }) => {
|
||||
if (!children) return null;
|
||||
|
||||
const menu = (
|
||||
<Dropdown.Menu className="bg-secondary text-white">
|
||||
{children}
|
||||
</Dropdown.Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown as={ButtonGroup}>
|
||||
<Dropdown className={className} as={ButtonGroup}>
|
||||
<Dropdown.Toggle variant="secondary" id="more-menu">
|
||||
<Icon icon={faEllipsisH} />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="bg-secondary text-white">
|
||||
{children}
|
||||
</Dropdown.Menu>
|
||||
{menuPortalTarget ? createPortal(menu, menuPortalTarget) : menu}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export const OperationDropdownItem: React.FC<{
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}> = ({ text, onClick, className }) => {
|
||||
return (
|
||||
<Dropdown.Item
|
||||
className={cx("bg-secondary text-white", className)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{text}
|
||||
</Dropdown.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export interface IListFilterOperation {
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
|
||||
73
ui/v2.5/src/components/List/ListResultsHeader.tsx
Normal file
73
ui/v2.5/src/components/List/ListResultsHeader.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { Pagination, PaginationIndex } from "../List/Pagination";
|
||||
import { ButtonToolbar } from "react-bootstrap";
|
||||
import { ListViewOptions } from "../List/ListViewOptions";
|
||||
import { PageSizeSelector, SortBySelect } from "../List/ListFilter";
|
||||
import cx from "classnames";
|
||||
|
||||
export const ListResultsHeader: React.FC<{
|
||||
className?: string;
|
||||
loading: boolean;
|
||||
filter: ListFilterModel;
|
||||
totalCount: number;
|
||||
metadataByline?: React.ReactNode;
|
||||
onChangeFilter: (filter: ListFilterModel) => void;
|
||||
}> = ({
|
||||
className,
|
||||
loading,
|
||||
filter,
|
||||
totalCount,
|
||||
metadataByline,
|
||||
onChangeFilter,
|
||||
}) => {
|
||||
return (
|
||||
<ButtonToolbar className={cx(className, "list-results-header")}>
|
||||
<div>
|
||||
<SortBySelect
|
||||
options={filter.options.sortByOptions}
|
||||
sortBy={filter.sortBy}
|
||||
sortDirection={filter.sortDirection}
|
||||
onChangeSortBy={(s) =>
|
||||
onChangeFilter(filter.setSortBy(s ?? undefined))
|
||||
}
|
||||
onChangeSortDirection={() =>
|
||||
onChangeFilter(filter.toggleSortDirection())
|
||||
}
|
||||
onReshuffleRandomSort={() =>
|
||||
onChangeFilter(filter.reshuffleRandomSort())
|
||||
}
|
||||
/>
|
||||
<PageSizeSelector
|
||||
pageSize={filter.itemsPerPage}
|
||||
setPageSize={(s) => onChangeFilter(filter.setPageSize(s))}
|
||||
/>
|
||||
<ListViewOptions
|
||||
displayMode={filter.displayMode}
|
||||
zoomIndex={filter.zoomIndex}
|
||||
displayModeOptions={filter.options.displayModeOptions}
|
||||
onSetDisplayMode={(mode) =>
|
||||
onChangeFilter(filter.setDisplayMode(mode))
|
||||
}
|
||||
onSetZoom={(zoom) => onChangeFilter(filter.setZoom(zoom))}
|
||||
/>
|
||||
</div>
|
||||
<div className="pagination-index-container">
|
||||
<Pagination
|
||||
currentPage={filter.currentPage}
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
totalItems={totalCount}
|
||||
onChangePage={(page) => onChangeFilter(filter.changePage(page))}
|
||||
/>
|
||||
<PaginationIndex
|
||||
loading={loading}
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
metadataByline={metadataByline}
|
||||
/>
|
||||
</div>
|
||||
<div className="empty-space"></div>
|
||||
</ButtonToolbar>
|
||||
);
|
||||
};
|
||||
141
ui/v2.5/src/components/List/ListToolbar.tsx
Normal file
141
ui/v2.5/src/components/List/ListToolbar.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FilterTags } from "../List/FilterTags";
|
||||
import cx from "classnames";
|
||||
import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap";
|
||||
import { FilterButton } from "../List/Filters/FilterButton";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { SearchTermInput } from "../List/ListFilter";
|
||||
import { Criterion } from "src/models/list-filter/criteria/criterion";
|
||||
import { SidebarToggleButton } from "../Shared/Sidebar";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { SavedFilterDropdown } from "./SavedFilterList";
|
||||
import { View } from "./views";
|
||||
|
||||
export const ToolbarFilterSection: React.FC<{
|
||||
filter: ListFilterModel;
|
||||
onToggleSidebar: () => void;
|
||||
onSetFilter: (filter: ListFilterModel) => void;
|
||||
onEditCriterion: (c?: Criterion) => void;
|
||||
onRemoveCriterion: (criterion: Criterion, valueIndex?: number) => void;
|
||||
onRemoveAllCriterion: () => void;
|
||||
onEditSearchTerm: () => void;
|
||||
onRemoveSearchTerm: () => void;
|
||||
view?: View;
|
||||
}> = PatchComponent(
|
||||
"ToolbarFilterSection",
|
||||
({
|
||||
filter,
|
||||
onToggleSidebar,
|
||||
onSetFilter,
|
||||
onEditCriterion,
|
||||
onRemoveCriterion,
|
||||
onRemoveAllCriterion,
|
||||
onEditSearchTerm,
|
||||
onRemoveSearchTerm,
|
||||
view,
|
||||
}) => {
|
||||
const { criteria, searchTerm } = filter;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="search-container">
|
||||
<SearchTermInput filter={filter} onFilterUpdate={onSetFilter} />
|
||||
</div>
|
||||
<div className="filter-section">
|
||||
<ButtonGroup>
|
||||
<SidebarToggleButton onClick={onToggleSidebar} />
|
||||
<SavedFilterDropdown
|
||||
filter={filter}
|
||||
onSetFilter={onSetFilter}
|
||||
view={view}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
<FilterButton
|
||||
onClick={() => onEditCriterion()}
|
||||
count={criteria.length}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
<FilterTags
|
||||
searchTerm={searchTerm}
|
||||
criteria={criteria}
|
||||
onEditCriterion={onEditCriterion}
|
||||
onRemoveCriterion={onRemoveCriterion}
|
||||
onRemoveAll={onRemoveAllCriterion}
|
||||
onEditSearchTerm={onEditSearchTerm}
|
||||
onRemoveSearchTerm={onRemoveSearchTerm}
|
||||
truncateOnOverflow
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const ToolbarSelectionSection: React.FC<{
|
||||
selected: number;
|
||||
onToggleSidebar: () => void;
|
||||
operations?: React.ReactNode;
|
||||
onSelectAll: () => void;
|
||||
onSelectNone: () => void;
|
||||
}> = PatchComponent(
|
||||
"ToolbarSelectionSection",
|
||||
({ selected, onToggleSidebar, operations, onSelectAll, onSelectNone }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className="toolbar-selection-section">
|
||||
<div className="selected-items-info">
|
||||
<SidebarToggleButton onClick={onToggleSidebar} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="minimal"
|
||||
onClick={() => onSelectNone()}
|
||||
title={intl.formatMessage({ id: "actions.select_none" })}
|
||||
>
|
||||
<Icon icon={faTimes} />
|
||||
</Button>
|
||||
<span>{selected} selected</span>
|
||||
<Button variant="link" onClick={() => onSelectAll()}>
|
||||
<FormattedMessage id="actions.select_all" />
|
||||
</Button>
|
||||
</div>
|
||||
{operations}
|
||||
<div className="empty-space" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// TODO - rename to FilteredListToolbar once all list components have been updated
|
||||
// TODO - and expose to plugins
|
||||
export const FilteredListToolbar2: React.FC<{
|
||||
className?: string;
|
||||
hasSelection: boolean;
|
||||
filterSection: React.ReactNode;
|
||||
selectionSection: React.ReactNode;
|
||||
operationSection: React.ReactNode;
|
||||
}> = ({
|
||||
className,
|
||||
hasSelection,
|
||||
filterSection,
|
||||
selectionSection,
|
||||
operationSection,
|
||||
}) => {
|
||||
return (
|
||||
<ButtonToolbar
|
||||
className={cx(className, "filtered-list-toolbar", {
|
||||
"has-selection": hasSelection,
|
||||
})}
|
||||
>
|
||||
{!hasSelection ? filterSection : selectionSection}
|
||||
{!hasSelection ? (
|
||||
<div className="filtered-list-toolbar-operations">
|
||||
{operationSection}
|
||||
</div>
|
||||
) : null}
|
||||
</ButtonToolbar>
|
||||
);
|
||||
};
|
||||
@@ -130,7 +130,8 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
|
||||
<div className="display-mode-menu">
|
||||
{onSetZoom &&
|
||||
zoomIndex !== undefined &&
|
||||
displayMode === DisplayMode.Grid ? (
|
||||
(displayMode === DisplayMode.Grid ||
|
||||
displayMode === DisplayMode.Wall) ? (
|
||||
<div className="zoom-slider-container">
|
||||
<ZoomSelect
|
||||
minZoom={minZoom}
|
||||
|
||||
@@ -44,7 +44,7 @@ const PageCount: React.FC<{
|
||||
useStopWheelScroll(pageInput);
|
||||
|
||||
const pageOptions = useMemo(() => {
|
||||
const maxPagesToShow = 10;
|
||||
const maxPagesToShow = 1000;
|
||||
const min = Math.max(1, currentPage - maxPagesToShow / 2);
|
||||
const max = Math.min(min + maxPagesToShow, totalPages);
|
||||
const pages = [];
|
||||
|
||||
@@ -30,12 +30,15 @@ import { faBookmark, faSave, faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
import { AlertModal } from "../Shared/Alert";
|
||||
import cx from "classnames";
|
||||
import { TruncatedInlineText } from "../Shared/TruncatedText";
|
||||
import { OperationButton } from "../Shared/OperationButton";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const ExistingSavedFilterList: React.FC<{
|
||||
name: string;
|
||||
setName: (name: string) => void;
|
||||
existing: { name: string; id: string }[];
|
||||
}> = ({ name, setName, existing }) => {
|
||||
onSelect: (value: SavedFilterDataFragment) => void;
|
||||
savedFilters: SavedFilterDataFragment[];
|
||||
disabled?: boolean;
|
||||
}> = ({ name, onSelect, savedFilters: existing, disabled = false }) => {
|
||||
const filtered = useMemo(() => {
|
||||
if (!name) return existing;
|
||||
|
||||
@@ -51,7 +54,8 @@ const ExistingSavedFilterList: React.FC<{
|
||||
<Button
|
||||
className="minimal"
|
||||
variant="link"
|
||||
onClick={() => setName(f.name)}
|
||||
onClick={() => onSelect(f)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{f.name}
|
||||
</Button>
|
||||
@@ -64,7 +68,8 @@ const ExistingSavedFilterList: React.FC<{
|
||||
export const SaveFilterDialog: React.FC<{
|
||||
mode: FilterMode;
|
||||
onClose: (name?: string, id?: string) => void;
|
||||
}> = ({ mode, onClose }) => {
|
||||
isSaving?: boolean;
|
||||
}> = ({ mode, onClose, isSaving = false }) => {
|
||||
const intl = useIntl();
|
||||
const [filterName, setFilterName] = useState("");
|
||||
|
||||
@@ -79,6 +84,74 @@ export const SaveFilterDialog: React.FC<{
|
||||
|
||||
return (
|
||||
<Modal show className="save-filter-dialog">
|
||||
<Modal.Header>
|
||||
<FormattedMessage id="actions.save_filter" />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<FormattedMessage id="filter_name" />
|
||||
</Form.Label>
|
||||
<FormControl
|
||||
className="bg-secondary text-white border-secondary"
|
||||
placeholder={`${intl.formatMessage({ id: "filter_name" })}…`}
|
||||
value={filterName}
|
||||
onChange={(e) => setFilterName(e.target.value)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<ExistingSavedFilterList
|
||||
name={filterName}
|
||||
onSelect={(f) => setFilterName(f.name)}
|
||||
savedFilters={data?.findSavedFilters ?? []}
|
||||
/>
|
||||
|
||||
{!!overwritingFilter && (
|
||||
<span className="saved-filter-overwrite-warning">
|
||||
<FormattedMessage
|
||||
id="dialogs.overwrite_filter_warning"
|
||||
values={{
|
||||
entityName: overwritingFilter.name,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onClose()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{intl.formatMessage({ id: "actions.cancel" })}
|
||||
</Button>
|
||||
<OperationButton
|
||||
loading={isSaving}
|
||||
variant="primary"
|
||||
onClick={() => onClose(filterName, overwritingFilter?.id)}
|
||||
>
|
||||
{intl.formatMessage({ id: "actions.save" })}
|
||||
</OperationButton>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const LoadFilterDialog: React.FC<{
|
||||
mode: FilterMode;
|
||||
onClose: (filter?: SavedFilterDataFragment) => void;
|
||||
}> = ({ mode, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const [filterName, setFilterName] = useState("");
|
||||
|
||||
const { data } = useFindSavedFilters(mode);
|
||||
|
||||
return (
|
||||
<Modal show className="load-filter-dialog">
|
||||
<Modal.Header>
|
||||
<FormattedMessage id="actions.load_filter" />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
@@ -94,31 +167,14 @@ export const SaveFilterDialog: React.FC<{
|
||||
|
||||
<ExistingSavedFilterList
|
||||
name={filterName}
|
||||
setName={setFilterName}
|
||||
existing={data?.findSavedFilters ?? []}
|
||||
onSelect={(f) => onClose(f)}
|
||||
savedFilters={data?.findSavedFilters ?? []}
|
||||
/>
|
||||
|
||||
{!!overwritingFilter && (
|
||||
<span className="saved-filter-overwrite-warning">
|
||||
<FormattedMessage
|
||||
id="dialogs.overwrite_filter_warning"
|
||||
values={{
|
||||
entityName: overwritingFilter.name,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={() => onClose()}>
|
||||
{intl.formatMessage({ id: "actions.cancel" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => onClose(filterName, overwritingFilter?.id)}
|
||||
>
|
||||
{intl.formatMessage({ id: "actions.save" })}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
@@ -188,6 +244,7 @@ interface ISavedFilterListProps {
|
||||
filter: ListFilterModel;
|
||||
onSetFilter: (f: ListFilterModel) => void;
|
||||
view?: View;
|
||||
menuPortalTarget?: Element | DocumentFragment;
|
||||
}
|
||||
|
||||
export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
||||
@@ -786,8 +843,15 @@ export const SavedFilterDropdown: React.FC<ISavedFilterListProps> = (props) => {
|
||||
));
|
||||
SavedFilterDropdownRef.displayName = "SavedFilterDropdown";
|
||||
|
||||
const menu = (
|
||||
<Dropdown.Menu
|
||||
as={SavedFilterDropdownRef}
|
||||
className="saved-filter-list-menu"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown as={ButtonGroup}>
|
||||
<Dropdown as={ButtonGroup} className="saved-filter-dropdown">
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={
|
||||
@@ -800,10 +864,9 @@ export const SavedFilterDropdown: React.FC<ISavedFilterListProps> = (props) => {
|
||||
<Icon icon={faBookmark} />
|
||||
</Dropdown.Toggle>
|
||||
</OverlayTrigger>
|
||||
<Dropdown.Menu
|
||||
as={SavedFilterDropdownRef}
|
||||
className="saved-filter-list-menu"
|
||||
/>
|
||||
{props.menuPortalTarget
|
||||
? createPortal(menu, props.menuPortalTarget)
|
||||
: menu}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -91,6 +91,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// hide zoom slider in xs viewport
|
||||
@include media-breakpoint-down(xs) {
|
||||
.display-mode-menu .zoom-slider-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.display-mode-popover {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
@@ -312,6 +319,40 @@ input[type="range"].zoom-slider {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
justify-content: space-between;
|
||||
|
||||
> div > :not(:first-child) {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.search-term-row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-left: 1.5rem;
|
||||
margin-right: 1rem;
|
||||
|
||||
.search-term-input {
|
||||
flex-basis: 75%;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
flex-wrap: wrap;
|
||||
|
||||
> span {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-term-input {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-tags {
|
||||
border-top: 1px solid rgb(16 22 26 / 40%);
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
@@ -412,11 +453,22 @@ input[type="range"].zoom-slider {
|
||||
}
|
||||
}
|
||||
|
||||
.filter-tags .clear-all-button {
|
||||
color: $text-color;
|
||||
// to match filter pills
|
||||
line-height: 16px;
|
||||
padding: 0;
|
||||
.filter-tags {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.more-tags {
|
||||
background-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.clear-all-button {
|
||||
color: $text-color;
|
||||
// to match filter pills
|
||||
line-height: 16px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
@@ -695,7 +747,7 @@ input[type="range"].zoom-slider {
|
||||
background-color: #202b33;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
@@ -929,39 +981,69 @@ input[type="range"].zoom-slider {
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
// make controls slightly larger on mobile
|
||||
@include media-breakpoint-down(xs) {
|
||||
.btn,
|
||||
.form-control {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-search-container {
|
||||
display: flex;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.search-term-input {
|
||||
flex-grow: 1;
|
||||
margin-right: 0.25rem;
|
||||
margin-right: 0;
|
||||
|
||||
.clearable-text-field {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-filter-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
background-color: $body-bg;
|
||||
bottom: 0;
|
||||
display: none;
|
||||
padding: 0.5rem;
|
||||
position: sticky;
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
.sidebar .search-term-input {
|
||||
margin-right: 0.5rem;
|
||||
.sidebar .sidebar-search-container {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-footer {
|
||||
background-color: transparent;
|
||||
bottom: $navbar-height;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: auto;
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
position: sticky;
|
||||
width: fit-content;
|
||||
z-index: 10;
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.pagination.btn-group {
|
||||
box-shadow: 0 8px 10px 2px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-bottom: 0;
|
||||
|
||||
@@ -971,3 +1053,350 @@ input[type="range"].zoom-slider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hide sidebar Edit Filter button on larger screens
|
||||
@include media-breakpoint-up(md) {
|
||||
.sidebar .edit-filter-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// the following refers to the new FilteredListToolbar2 component
|
||||
// ensure the rules here don't conflict with the original filtered-list-toolbar above
|
||||
// TODO - replace with only .filtered-list-toolbar once all lists use the new toolbar
|
||||
.scene-list-toolbar {
|
||||
&.filtered-list-toolbar {
|
||||
align-items: center;
|
||||
background-color: $body-bg;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
row-gap: 1rem;
|
||||
|
||||
> div {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
|
||||
&:last-child {
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.filtered-list-toolbar {
|
||||
flex-wrap: nowrap;
|
||||
gap: 1rem;
|
||||
// offset the main padding
|
||||
margin-top: -0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
position: sticky;
|
||||
top: $navbar-height;
|
||||
z-index: 10;
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
// hide drop down menu items for play and create new
|
||||
// when the buttons are visible
|
||||
@include media-breakpoint-up(sm) {
|
||||
.scene-list-operations {
|
||||
.play-item,
|
||||
.create-new-item {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hide play and create new buttons on xs screens
|
||||
// show these in the drop down menu instead
|
||||
@include media-breakpoint-down(xs) {
|
||||
.play-button,
|
||||
.create-new-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-selection-section,
|
||||
div.filter-section {
|
||||
border: 1px solid $secondary;
|
||||
border-radius: 0.25rem;
|
||||
flex-grow: 1;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
div.toolbar-selection-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: center;
|
||||
|
||||
.sidebar-toggle-button {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.selected-items-info {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
> div:first-child,
|
||||
> div:last-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
> div:last-child {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.scene-list-operations {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
// on smaller viewports move the operation buttons to the right
|
||||
@include media-breakpoint-down(md) {
|
||||
div.scene-list-operations {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
order: 3;
|
||||
}
|
||||
|
||||
> div:last-child {
|
||||
flex: 0;
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// on larger viewports, move the operation buttons to the center
|
||||
@include media-breakpoint-up(lg) {
|
||||
div.toolbar-selection-section div.scene-list-operations {
|
||||
justify-content: center;
|
||||
|
||||
> .btn-group {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
div.toolbar-selection-section .empty-space {
|
||||
flex: 1;
|
||||
order: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
border-right: 1px solid $secondary;
|
||||
display: flex;
|
||||
margin-right: -0.5rem;
|
||||
min-width: calc($sidebar-width - 15px);
|
||||
padding-right: 10px;
|
||||
|
||||
.search-term-input {
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
|
||||
.clearable-text-field {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-tags {
|
||||
flex-grow: 1;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 0;
|
||||
|
||||
// account for filter button, and toggle sidebar buttons with gaps
|
||||
width: calc(100% - 70px - 1rem);
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hide the search box in the toolbar when sidebar is shown on larger screens
|
||||
// larger screens don't overlap the sidebar
|
||||
@include media-breakpoint-up(md) {
|
||||
.sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .search-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
// hide the search box when sidebar is hidden on smaller screens
|
||||
@include media-breakpoint-down(md) {
|
||||
.sidebar-pane.hide-sidebar .filtered-list-toolbar .search-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// hide the filter and saved filters icon buttons when sidebar is shown on smaller screens
|
||||
@include media-breakpoint-down(sm) {
|
||||
.sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar {
|
||||
.filter-button,
|
||||
.saved-filter-dropdown {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// adjust the width of the filter-tags as well
|
||||
.sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .filter-tags {
|
||||
width: calc(100% - 35px - 0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
// move the sidebar toggle to the left on larger viewports
|
||||
@include media-breakpoint-up(md) {
|
||||
.filtered-list-toolbar .filter-section {
|
||||
.sidebar-toggle-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.filter-tags {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hide the search term tag item when the search box is visible
|
||||
@include media-breakpoint-up(lg) {
|
||||
// TODO - remove scene-list-toolbar when all lists use the new toolbar
|
||||
.scene-list-toolbar.filtered-list-toolbar
|
||||
.filter-tags
|
||||
.search-term-filter-tag {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@include media-breakpoint-down(md) {
|
||||
// TODO - remove scene-list-toolbar when all lists use the new toolbar
|
||||
.sidebar-pane:not(.hide-sidebar)
|
||||
.scene-list-toolbar.filtered-list-toolbar
|
||||
.filter-tags
|
||||
.search-term-filter-tag {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO - remove scene-list-toolbar when all lists use the new toolbar
|
||||
.detail-body .scene-list-toolbar.filtered-list-toolbar {
|
||||
top: calc($sticky-detail-header-height + $navbar-height);
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#more-criteria-popover {
|
||||
box-shadow: 0 8px 10px 2px rgb(0 0 0 / 30%);
|
||||
max-width: 400px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.list-results-header {
|
||||
align-items: flex-start;
|
||||
background-color: $body-bg;
|
||||
display: flex;
|
||||
|
||||
> div {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
|
||||
&.pagination-index-container {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-results-header .pagination-index-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
.pagination {
|
||||
// hidden by default. Can be shown via css override if needed
|
||||
display: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.list-results-header {
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.paginationIndex {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// move pagination info to right on medium screens
|
||||
@include media-breakpoint-down(md) {
|
||||
& > .empty-space {
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
& > div.pagination-index-container {
|
||||
align-items: flex-end;
|
||||
order: 3;
|
||||
}
|
||||
}
|
||||
|
||||
// center the header on smaller screens
|
||||
@include media-breakpoint-down(sm) {
|
||||
& > div,
|
||||
& > div.pagination-index-container {
|
||||
flex-basis: 100%;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
& > div.pagination-index-container {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sidebar visible styling
|
||||
.sidebar-pane:not(.hide-sidebar) .list-results-header {
|
||||
// move pagination info to right on medium screens when sidebar
|
||||
@include media-breakpoint-down(lg) {
|
||||
& > .empty-space {
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
& > div.pagination-index-container {
|
||||
justify-content: flex-end;
|
||||
order: 3;
|
||||
}
|
||||
}
|
||||
|
||||
// center the header on smaller screens when sidebar is visible
|
||||
@include media-breakpoint-down(md) {
|
||||
& > div,
|
||||
& > div.pagination-index-container {
|
||||
flex-basis: 100%;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ import * as GQL from "src/core/generated-graphql";
|
||||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
import { Criterion } from "src/models/list-filter/criteria/criterion";
|
||||
|
||||
function locationEquals(
|
||||
loc1: ReturnType<typeof useLocation> | undefined,
|
||||
loc2: ReturnType<typeof useLocation>
|
||||
) {
|
||||
return loc1 && loc1.pathname === loc2.pathname && loc1.search === loc2.search;
|
||||
}
|
||||
|
||||
export function useFilterURL(
|
||||
filter: ListFilterModel,
|
||||
setFilter: React.Dispatch<React.SetStateAction<ListFilterModel>>,
|
||||
@@ -24,6 +31,7 @@ export function useFilterURL(
|
||||
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const prevLocation = usePrevious(location);
|
||||
|
||||
// when the filter changes, update the URL
|
||||
const updateFilter = useCallback(
|
||||
@@ -47,7 +55,8 @@ export function useFilterURL(
|
||||
// and updates the filter accordingly.
|
||||
useEffect(() => {
|
||||
// don't apply if active is false
|
||||
if (!active) return;
|
||||
// also don't apply if location is unchanged
|
||||
if (!active || locationEquals(prevLocation, location)) return;
|
||||
|
||||
// re-init to load default filter on empty new query params
|
||||
if (!location.search) {
|
||||
@@ -73,7 +82,8 @@ export function useFilterURL(
|
||||
});
|
||||
}, [
|
||||
active,
|
||||
location.search,
|
||||
prevLocation,
|
||||
location,
|
||||
defaultFilter,
|
||||
setFilter,
|
||||
updateFilter,
|
||||
@@ -196,9 +206,12 @@ export function useFilterOperations(props: {
|
||||
[setFilter]
|
||||
);
|
||||
|
||||
const clearAllCriteria = useCallback(() => {
|
||||
setFilter((cv) => cv.clearCriteria());
|
||||
}, [setFilter]);
|
||||
const clearAllCriteria = useCallback(
|
||||
(includeSearchTerm = false) => {
|
||||
setFilter((cv) => cv.clearCriteria(includeSearchTerm));
|
||||
},
|
||||
[setFilter]
|
||||
);
|
||||
|
||||
return {
|
||||
setPage,
|
||||
|
||||
@@ -103,7 +103,6 @@ const allMenuItems: IMenuItem[] = [
|
||||
href: "/scenes",
|
||||
icon: faPlayCircle,
|
||||
hotkey: "g s",
|
||||
userCreatable: true,
|
||||
},
|
||||
{
|
||||
name: "images",
|
||||
|
||||
@@ -47,6 +47,7 @@ import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage";
|
||||
import { LightboxLink } from "src/hooks/Lightbox/LightboxLink";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { ILightboxImage } from "src/hooks/Lightbox/types";
|
||||
import { goBackOrReplace } from "src/utils/history";
|
||||
|
||||
interface IProps {
|
||||
performer: GQL.PerformerDataFragment;
|
||||
@@ -330,7 +331,7 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
|
||||
return;
|
||||
}
|
||||
|
||||
history.goBack();
|
||||
goBackOrReplace(history, "/performers");
|
||||
}
|
||||
|
||||
function toggleEditing(value?: boolean) {
|
||||
|
||||
@@ -466,7 +466,6 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
||||
setScraper(undefined);
|
||||
} else {
|
||||
setScrapedPerformer(result);
|
||||
updateStashIDs(performerResult.remote_site_id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,24 @@ export const SceneDuplicateChecker: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const scenes = data?.findDuplicateScenes ?? [];
|
||||
const getGroupTotalSize = (group: GQL.SlimSceneDataFragment[]) => {
|
||||
// Sum all file sizes across all scenes in the group
|
||||
return group.reduce((groupTotal, scene) => {
|
||||
const sceneTotal = scene.files.reduce(
|
||||
(fileTotal, file) => fileTotal + file.size,
|
||||
0
|
||||
);
|
||||
return groupTotal + sceneTotal;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const scenes = useMemo(() => {
|
||||
const groups = data?.findDuplicateScenes ?? [];
|
||||
// Sort by total file size descending (largest groups first)
|
||||
return [...groups].sort((a, b) => {
|
||||
return getGroupTotalSize(b) - getGroupTotalSize(a);
|
||||
});
|
||||
}, [data?.findDuplicateScenes]);
|
||||
|
||||
const { data: missingPhash } = GQL.useFindScenesQuery({
|
||||
variables: {
|
||||
|
||||
@@ -120,6 +120,22 @@ function handleHotkeys(player: VideoJsPlayer, event: videojs.KeyboardEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const skipButtons = player.skipButtons();
|
||||
if (skipButtons) {
|
||||
// handle multimedia keys
|
||||
switch (event.key) {
|
||||
case "MediaTrackNext":
|
||||
if (!skipButtons.onNext) return;
|
||||
skipButtons.onNext();
|
||||
break;
|
||||
case "MediaTrackPrevious":
|
||||
if (!skipButtons.onPrevious) return;
|
||||
skipButtons.onPrevious();
|
||||
break;
|
||||
// MediaPlayPause handled by videojs
|
||||
}
|
||||
}
|
||||
|
||||
switch (event.which) {
|
||||
case 32: // space
|
||||
case 13: // enter
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface IMarker {
|
||||
title: string;
|
||||
seconds: number;
|
||||
end_seconds?: number | null;
|
||||
primaryTag: { name: string };
|
||||
}
|
||||
|
||||
interface IMarkersOptions {
|
||||
@@ -85,8 +86,13 @@ class MarkersPlugin extends videojs.getPlugin("plugin") {
|
||||
markerSet.dot.toggleAttribute("marker-tooltip-shown", true);
|
||||
|
||||
// Set background color based on tag (if available)
|
||||
if (marker.title && this.tagColors[marker.title]) {
|
||||
markerSet.dot.style.backgroundColor = this.tagColors[marker.title];
|
||||
if (
|
||||
marker.primaryTag &&
|
||||
marker.primaryTag.name &&
|
||||
this.tagColors[marker.primaryTag.name]
|
||||
) {
|
||||
markerSet.dot.style.backgroundColor =
|
||||
this.tagColors[marker.primaryTag.name];
|
||||
}
|
||||
markerSet.dot.addEventListener("mouseenter", () => {
|
||||
this.showMarkerTooltip(marker.title);
|
||||
@@ -152,8 +158,12 @@ class MarkersPlugin extends videojs.getPlugin("plugin") {
|
||||
rangeDiv.style.display = "none"; // Initially hidden
|
||||
|
||||
// Set background color based on tag (if available)
|
||||
if (marker.title && this.tagColors[marker.title]) {
|
||||
rangeDiv.style.backgroundColor = this.tagColors[marker.title];
|
||||
if (
|
||||
marker.primaryTag &&
|
||||
marker.primaryTag.name &&
|
||||
this.tagColors[marker.primaryTag.name]
|
||||
) {
|
||||
rangeDiv.style.backgroundColor = this.tagColors[marker.primaryTag.name];
|
||||
}
|
||||
|
||||
markerSet.range = rangeDiv;
|
||||
|
||||
@@ -2,5 +2,6 @@ import videojs from "video.js";
|
||||
|
||||
export const VIDEO_PLAYER_ID = "VideoJsPlayer";
|
||||
|
||||
export const getPlayerPosition = () =>
|
||||
videojs.getPlayer(VIDEO_PLAYER_ID)?.currentTime();
|
||||
export const getPlayer = () => videojs.getPlayer(VIDEO_PLAYER_ID);
|
||||
|
||||
export const getPlayerPosition = () => getPlayer()?.currentTime();
|
||||
|
||||
@@ -51,6 +51,7 @@ import { lazyComponent } from "src/utils/lazyComponent";
|
||||
import cx from "classnames";
|
||||
import { TruncatedText } from "src/components/Shared/TruncatedText";
|
||||
import { PatchComponent, PatchContainerComponent } from "src/patch";
|
||||
import { goBackOrReplace } from "src/utils/history";
|
||||
|
||||
const SubmitStashBoxDraft = lazyComponent(
|
||||
() => import("src/components/Dialogs/SubmitDraft")
|
||||
@@ -909,7 +910,7 @@ const SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({
|
||||
) {
|
||||
loadScene(queueScenes[currentQueueIndex + 1].id);
|
||||
} else {
|
||||
history.goBack();
|
||||
goBackOrReplace(history, "/scenes");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,12 @@ import { SceneCardsGrid } from "./SceneCardsGrid";
|
||||
import { TaggerContext } from "../Tagger/context";
|
||||
import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { faPlay } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faPencil,
|
||||
faPlay,
|
||||
faPlus,
|
||||
faTrash,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { SceneMergeModal } from "./SceneMergeDialog";
|
||||
import { objectTitle } from "src/core/files";
|
||||
import TextUtils from "src/utils/text";
|
||||
@@ -27,11 +32,18 @@ import { View } from "../List/views";
|
||||
import { FileSize } from "../Shared/FileSize";
|
||||
import { LoadedContent } from "../List/PagedList";
|
||||
import { useCloseEditDelete, useFilterOperations } from "../List/util";
|
||||
import { IListFilterOperation } from "../List/ListOperationButtons";
|
||||
import { FilteredListToolbar } from "../List/FilteredListToolbar";
|
||||
import {
|
||||
OperationDropdown,
|
||||
OperationDropdownItem,
|
||||
} from "../List/ListOperationButtons";
|
||||
import { useFilteredItemList } from "../List/ItemList";
|
||||
import { FilterTags } from "../List/FilterTags";
|
||||
import { Sidebar, SidebarPane, useSidebarState } from "../Shared/Sidebar";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarPane,
|
||||
SidebarPaneContent,
|
||||
SidebarStateContext,
|
||||
useSidebarState,
|
||||
} from "../Shared/Sidebar";
|
||||
import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter";
|
||||
import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter";
|
||||
import { PerformersCriterionOption } from "src/models/list-filter/criteria/performers";
|
||||
@@ -48,7 +60,16 @@ import {
|
||||
useFilteredSidebarKeybinds,
|
||||
} from "../List/Filters/FilterSidebar";
|
||||
import { PatchContainerComponent } from "src/patch";
|
||||
import { Pagination, PaginationIndex } from "../List/Pagination";
|
||||
import { Pagination } from "../List/Pagination";
|
||||
import { Button, ButtonGroup } from "react-bootstrap";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import useFocus from "src/utils/focus";
|
||||
import {
|
||||
FilteredListToolbar2,
|
||||
ToolbarFilterSection,
|
||||
ToolbarSelectionSection,
|
||||
} from "../List/ListToolbar";
|
||||
import { ListResultsHeader } from "../List/ListResultsHeader";
|
||||
|
||||
function renderMetadataByline(result: GQL.FindScenesQueryResult) {
|
||||
const duration = result?.data?.findScenes?.duration;
|
||||
@@ -82,33 +103,51 @@ function renderMetadataByline(result: GQL.FindScenesQueryResult) {
|
||||
function usePlayScene() {
|
||||
const history = useHistory();
|
||||
|
||||
const { configuration: config } = useContext(ConfigurationContext);
|
||||
const cont = config?.interface.continuePlaylistDefault ?? false;
|
||||
const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false;
|
||||
|
||||
const playScene = useCallback(
|
||||
(queue: SceneQueue, sceneID: string, options: IPlaySceneOptions) => {
|
||||
history.push(queue.makeLink(sceneID, options));
|
||||
(queue: SceneQueue, sceneID: string, options?: IPlaySceneOptions) => {
|
||||
history.push(
|
||||
queue.makeLink(sceneID, { autoPlay, continue: cont, ...options })
|
||||
);
|
||||
},
|
||||
[history]
|
||||
[history, cont, autoPlay]
|
||||
);
|
||||
|
||||
return playScene;
|
||||
}
|
||||
|
||||
function usePlaySelected(selectedIds: Set<string>) {
|
||||
const { configuration: config } = useContext(ConfigurationContext);
|
||||
const playScene = usePlayScene();
|
||||
|
||||
const playSelected = useCallback(() => {
|
||||
// populate queue and go to first scene
|
||||
const sceneIDs = Array.from(selectedIds.values());
|
||||
const queue = SceneQueue.fromSceneIDList(sceneIDs);
|
||||
const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false;
|
||||
playScene(queue, sceneIDs[0], { autoPlay });
|
||||
}, [selectedIds, config?.interface.autostartVideoOnPlaySelected, playScene]);
|
||||
|
||||
playScene(queue, sceneIDs[0]);
|
||||
}, [selectedIds, playScene]);
|
||||
|
||||
return playSelected;
|
||||
}
|
||||
|
||||
function usePlayFirst() {
|
||||
const playScene = usePlayScene();
|
||||
|
||||
const playFirst = useCallback(
|
||||
(queue: SceneQueue, sceneID: string, index: number) => {
|
||||
// populate queue and go to first scene
|
||||
playScene(queue, sceneID, { sceneIndex: index });
|
||||
},
|
||||
[playScene]
|
||||
);
|
||||
|
||||
return playFirst;
|
||||
}
|
||||
|
||||
function usePlayRandom(filter: ListFilterModel, count: number) {
|
||||
const { configuration: config } = useContext(ConfigurationContext);
|
||||
const playScene = usePlayScene();
|
||||
|
||||
const playRandom = useCallback(async () => {
|
||||
@@ -130,15 +169,9 @@ function usePlayRandom(filter: ListFilterModel, count: number) {
|
||||
if (scene) {
|
||||
// navigate to the image player page
|
||||
const queue = SceneQueue.fromListFilterModel(filterCopy);
|
||||
const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false;
|
||||
playScene(queue, scene.id, { sceneIndex: index, autoPlay });
|
||||
playScene(queue, scene.id, { sceneIndex: index });
|
||||
}
|
||||
}, [
|
||||
filter,
|
||||
count,
|
||||
config?.interface.autostartVideoOnPlaySelected,
|
||||
playScene,
|
||||
]);
|
||||
}, [filter, count, playScene]);
|
||||
|
||||
return playRandom;
|
||||
}
|
||||
@@ -193,7 +226,13 @@ const SceneList: React.FC<{
|
||||
);
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.Wall) {
|
||||
return <SceneWallPanel scenes={scenes} sceneQueue={queue} />;
|
||||
return (
|
||||
<SceneWallPanel
|
||||
scenes={scenes}
|
||||
sceneQueue={queue}
|
||||
zoomIndex={filter.zoomIndex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.Tagger) {
|
||||
return <Tagger scenes={scenes} queue={queue} />;
|
||||
@@ -209,36 +248,60 @@ const ScenesFilterSidebarSections = PatchContainerComponent(
|
||||
const SidebarContent: React.FC<{
|
||||
filter: ListFilterModel;
|
||||
setFilter: (filter: ListFilterModel) => void;
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
view?: View;
|
||||
sidebarOpen: boolean;
|
||||
onClose?: () => void;
|
||||
showEditFilter: (editingCriterion?: string) => void;
|
||||
}> = ({ filter, setFilter, view, showEditFilter, sidebarOpen, onClose }) => {
|
||||
count?: number;
|
||||
focus?: ReturnType<typeof useFocus>;
|
||||
}> = ({
|
||||
filter,
|
||||
setFilter,
|
||||
filterHook,
|
||||
view,
|
||||
showEditFilter,
|
||||
sidebarOpen,
|
||||
onClose,
|
||||
count,
|
||||
focus,
|
||||
}) => {
|
||||
const showResultsId =
|
||||
count !== undefined ? "actions.show_count_results" : "actions.show_results";
|
||||
|
||||
const hideStudios = view === View.StudioScenes;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilteredSidebarHeader
|
||||
sidebarOpen={sidebarOpen}
|
||||
onClose={onClose}
|
||||
showEditFilter={showEditFilter}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
view={view}
|
||||
focus={focus}
|
||||
/>
|
||||
|
||||
<ScenesFilterSidebarSections>
|
||||
<SidebarStudiosFilter
|
||||
title={<FormattedMessage id="studios" />}
|
||||
data-type={StudiosCriterionOption.type}
|
||||
option={StudiosCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
{!hideStudios && (
|
||||
<SidebarStudiosFilter
|
||||
title={<FormattedMessage id="studios" />}
|
||||
data-type={StudiosCriterionOption.type}
|
||||
option={StudiosCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterHook={filterHook}
|
||||
sectionID="studios"
|
||||
/>
|
||||
)}
|
||||
<SidebarPerformersFilter
|
||||
title={<FormattedMessage id="performers" />}
|
||||
data-type={PerformersCriterionOption.type}
|
||||
option={PerformersCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterHook={filterHook}
|
||||
sectionID="performers"
|
||||
/>
|
||||
<SidebarTagsFilter
|
||||
title={<FormattedMessage id="tags" />}
|
||||
@@ -246,6 +309,8 @@ const SidebarContent: React.FC<{
|
||||
option={TagsCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterHook={filterHook}
|
||||
sectionID="tags"
|
||||
/>
|
||||
<SidebarRatingFilter
|
||||
title={<FormattedMessage id="rating" />}
|
||||
@@ -253,6 +318,7 @@ const SidebarContent: React.FC<{
|
||||
option={RatingCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sectionID="rating"
|
||||
/>
|
||||
<SidebarBooleanFilter
|
||||
title={<FormattedMessage id="organized" />}
|
||||
@@ -260,12 +326,111 @@ const SidebarContent: React.FC<{
|
||||
option={OrganizedCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sectionID="organized"
|
||||
/>
|
||||
</ScenesFilterSidebarSections>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<Button className="sidebar-close-button" onClick={onClose}>
|
||||
<FormattedMessage id={showResultsId} values={{ count }} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IOperations {
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
isDisplayed?: () => boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SceneListOperations: React.FC<{
|
||||
items: number;
|
||||
hasSelection: boolean;
|
||||
operations: IOperations[];
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onPlay: () => void;
|
||||
onCreateNew: () => void;
|
||||
}> = ({
|
||||
items,
|
||||
hasSelection,
|
||||
operations,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onPlay,
|
||||
onCreateNew,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className="scene-list-operations">
|
||||
<ButtonGroup>
|
||||
{!!items && (
|
||||
<Button
|
||||
className="play-button"
|
||||
variant="secondary"
|
||||
onClick={() => onPlay()}
|
||||
title={intl.formatMessage({ id: "actions.play" })}
|
||||
>
|
||||
<Icon icon={faPlay} />
|
||||
</Button>
|
||||
)}
|
||||
{!hasSelection && (
|
||||
<Button
|
||||
className="create-new-button"
|
||||
variant="secondary"
|
||||
onClick={() => onCreateNew()}
|
||||
title={intl.formatMessage(
|
||||
{ id: "actions.create_entity" },
|
||||
{ entityType: intl.formatMessage({ id: "scene" }) }
|
||||
)}
|
||||
>
|
||||
<Icon icon={faPlus} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasSelection && (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => onEdit()}>
|
||||
<Icon icon={faPencil} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="btn-danger-minimal"
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
<Icon icon={faTrash} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<OperationDropdown
|
||||
className="scene-list-operations"
|
||||
menuPortalTarget={document.body}
|
||||
>
|
||||
{operations.map((o) => {
|
||||
if (o.isDisplayed && !o.isDisplayed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<OperationDropdownItem
|
||||
key={o.text}
|
||||
onClick={o.onClick}
|
||||
text={o.text}
|
||||
className={o.className}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</OperationDropdown>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IFilteredScenes {
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
defaultSort?: string;
|
||||
@@ -278,6 +443,9 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
|
||||
const searchFocus = useFocus();
|
||||
const [, setSearchFocus] = searchFocus;
|
||||
|
||||
const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props;
|
||||
|
||||
// States
|
||||
@@ -285,6 +453,8 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
loading: sidebarStateLoading,
|
||||
sectionOpen,
|
||||
setSectionOpen,
|
||||
} = useSidebarState(view);
|
||||
|
||||
const { filterState, queryResult, modalState, listSelect, showEditFilter } =
|
||||
@@ -312,6 +482,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
selectedIds,
|
||||
selectedItems,
|
||||
onSelectChange,
|
||||
onSelectAll,
|
||||
onSelectNone,
|
||||
hasSelection,
|
||||
} = listSelect;
|
||||
@@ -330,6 +501,25 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
setShowSidebar,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("e", () => {
|
||||
if (hasSelection) {
|
||||
onEdit?.();
|
||||
}
|
||||
});
|
||||
|
||||
Mousetrap.bind("d d", () => {
|
||||
if (hasSelection) {
|
||||
onDelete?.();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("e");
|
||||
Mousetrap.unbind("d d");
|
||||
};
|
||||
});
|
||||
|
||||
const onCloseEditDelete = useCloseEditDelete({
|
||||
closeModal,
|
||||
onSelectNone,
|
||||
@@ -337,13 +527,36 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
});
|
||||
|
||||
const metadataByline = useMemo(() => {
|
||||
if (cachedResult.loading) return "";
|
||||
if (cachedResult.loading) return null;
|
||||
|
||||
return renderMetadataByline(cachedResult) ?? "";
|
||||
return renderMetadataByline(cachedResult) ?? null;
|
||||
}, [cachedResult]);
|
||||
|
||||
const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]);
|
||||
|
||||
const playRandom = usePlayRandom(effectiveFilter, totalCount);
|
||||
const playSelected = usePlaySelected(selectedIds);
|
||||
const playRandom = usePlayRandom(filter, totalCount);
|
||||
const playFirst = usePlayFirst();
|
||||
|
||||
function onCreateNew() {
|
||||
history.push("/scenes/new");
|
||||
}
|
||||
|
||||
function onPlay() {
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if there are selected items, play those
|
||||
if (hasSelection) {
|
||||
playSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise, play the first item in the list
|
||||
const sceneID = items[0].id;
|
||||
playFirst(queue, sceneID, 0);
|
||||
}
|
||||
|
||||
function onExport(all: boolean) {
|
||||
showModal(
|
||||
@@ -381,16 +594,51 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
);
|
||||
}
|
||||
|
||||
const otherOperations: IListFilterOperation[] = [
|
||||
function onEdit() {
|
||||
showModal(
|
||||
<EditScenesDialog selected={selectedItems} onClose={onCloseEditDelete} />
|
||||
);
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
showModal(
|
||||
<DeleteScenesDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const otherOperations = [
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.play_selected" }),
|
||||
onClick: playSelected,
|
||||
text: intl.formatMessage({ id: "actions.play" }),
|
||||
onClick: () => onPlay(),
|
||||
isDisplayed: () => items.length > 0,
|
||||
className: "play-item",
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
{ id: "actions.create_entity" },
|
||||
{ entityType: intl.formatMessage({ id: "scene" }) }
|
||||
),
|
||||
onClick: () => onCreateNew(),
|
||||
isDisplayed: () => !hasSelection,
|
||||
className: "create-new-item",
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.select_all" }),
|
||||
onClick: () => onSelectAll(),
|
||||
isDisplayed: () => totalCount > 0,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.select_none" }),
|
||||
onClick: () => onSelectNone(),
|
||||
isDisplayed: () => hasSelection,
|
||||
icon: faPlay,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.play_random" }),
|
||||
onClick: playRandom,
|
||||
isDisplayed: () => totalCount > 1,
|
||||
},
|
||||
{
|
||||
text: `${intl.formatMessage({ id: "actions.generate" })}…`,
|
||||
@@ -434,6 +682,18 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
// render
|
||||
if (filterLoading || sidebarStateLoading) return null;
|
||||
|
||||
const operations = (
|
||||
<SceneListOperations
|
||||
items={items.length}
|
||||
hasSelection={hasSelection}
|
||||
operations={otherOperations}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onPlay={onPlay}
|
||||
onCreateNew={onCreateNew}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<TaggerContext>
|
||||
<div
|
||||
@@ -443,84 +703,90 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
>
|
||||
{modal}
|
||||
|
||||
<SidebarPane hideSidebar={!showSidebar}>
|
||||
<Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>
|
||||
<SidebarContent
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
showEditFilter={showEditFilter}
|
||||
view={view}
|
||||
sidebarOpen={showSidebar}
|
||||
onClose={() => setShowSidebar(false)}
|
||||
/>
|
||||
</Sidebar>
|
||||
<div>
|
||||
<FilteredListToolbar
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
showEditFilter={showEditFilter}
|
||||
view={view}
|
||||
listSelect={listSelect}
|
||||
onEdit={() =>
|
||||
showModal(
|
||||
<EditScenesDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onDelete={() => {
|
||||
showModal(
|
||||
<DeleteScenesDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
operations={otherOperations}
|
||||
onToggleSidebar={() => setShowSidebar((v) => !v)}
|
||||
zoomable
|
||||
/>
|
||||
|
||||
<FilterTags
|
||||
criteria={filter.criteria}
|
||||
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
|
||||
onRemoveCriterion={removeCriterion}
|
||||
onRemoveAll={() => clearAllCriteria()}
|
||||
/>
|
||||
|
||||
<PaginationIndex
|
||||
loading={cachedResult.loading}
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
metadataByline={metadataByline}
|
||||
/>
|
||||
|
||||
<LoadedContent loading={result.loading} error={result.error}>
|
||||
<SceneList
|
||||
filter={effectiveFilter}
|
||||
scenes={items}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
fromGroupId={fromGroupId}
|
||||
<SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>
|
||||
<SidebarPane hideSidebar={!showSidebar}>
|
||||
<Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>
|
||||
<SidebarContent
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterHook={filterHook}
|
||||
showEditFilter={showEditFilter}
|
||||
view={view}
|
||||
sidebarOpen={showSidebar}
|
||||
onClose={() => setShowSidebar(false)}
|
||||
count={cachedResult.loading ? undefined : totalCount}
|
||||
focus={searchFocus}
|
||||
/>
|
||||
</Sidebar>
|
||||
<SidebarPaneContent>
|
||||
<FilteredListToolbar2
|
||||
className="scene-list-toolbar"
|
||||
hasSelection={hasSelection}
|
||||
filterSection={
|
||||
<ToolbarFilterSection
|
||||
filter={filter}
|
||||
onSetFilter={setFilter}
|
||||
onToggleSidebar={() => setShowSidebar(!showSidebar)}
|
||||
onEditCriterion={(c) =>
|
||||
showEditFilter(c?.criterionOption.type)
|
||||
}
|
||||
onRemoveCriterion={removeCriterion}
|
||||
onRemoveAllCriterion={() => clearAllCriteria(true)}
|
||||
onEditSearchTerm={() => {
|
||||
setShowSidebar(true);
|
||||
setSearchFocus(true);
|
||||
}}
|
||||
onRemoveSearchTerm={() =>
|
||||
setFilter(filter.clearSearchTerm())
|
||||
}
|
||||
view={view}
|
||||
/>
|
||||
}
|
||||
selectionSection={
|
||||
<ToolbarSelectionSection
|
||||
selected={selectedIds.size}
|
||||
onToggleSidebar={() => setShowSidebar(!showSidebar)}
|
||||
onSelectAll={() => onSelectAll()}
|
||||
onSelectNone={() => onSelectNone()}
|
||||
operations={operations}
|
||||
/>
|
||||
}
|
||||
operationSection={operations}
|
||||
/>
|
||||
</LoadedContent>
|
||||
|
||||
{totalCount > filter.itemsPerPage && (
|
||||
<div className="pagination-footer">
|
||||
<Pagination
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
metadataByline={metadataByline}
|
||||
onChangePage={setPage}
|
||||
pagePopupPlacement="top"
|
||||
<ListResultsHeader
|
||||
loading={cachedResult.loading}
|
||||
filter={filter}
|
||||
totalCount={totalCount}
|
||||
metadataByline={metadataByline}
|
||||
onChangeFilter={(newFilter) => setFilter(newFilter)}
|
||||
/>
|
||||
|
||||
<LoadedContent loading={result.loading} error={result.error}>
|
||||
<SceneList
|
||||
filter={effectiveFilter}
|
||||
scenes={items}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
fromGroupId={fromGroupId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarPane>
|
||||
</LoadedContent>
|
||||
|
||||
{totalCount > filter.itemsPerPage && (
|
||||
<div className="pagination-footer">
|
||||
<Pagination
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
metadataByline={metadataByline}
|
||||
onChangePage={setPage}
|
||||
pagePopupPlacement="top"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SidebarPaneContent>
|
||||
</SidebarPane>
|
||||
</SidebarStateContext.Provider>
|
||||
</div>
|
||||
</TaggerContext>
|
||||
);
|
||||
|
||||
@@ -95,7 +95,10 @@ export const SceneMarkerList: React.FC<ISceneMarkerList> = ({
|
||||
|
||||
if (filter.displayMode === DisplayMode.Wall) {
|
||||
return (
|
||||
<MarkerWallPanel markers={result.data.findSceneMarkers.scene_markers} />
|
||||
<MarkerWallPanel
|
||||
markers={result.data.findSceneMarkers.scene_markers}
|
||||
zoomIndex={filter.zoomIndex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,15 +39,23 @@ interface IMarkerPhoto {
|
||||
onError?: (photo: PhotoProps<IMarkerPhoto>) => void;
|
||||
}
|
||||
|
||||
export const MarkerWallItem: React.FC<RenderImageProps<IMarkerPhoto>> = (
|
||||
props: RenderImageProps<IMarkerPhoto>
|
||||
) => {
|
||||
interface IExtraProps {
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
export const MarkerWallItem: React.FC<
|
||||
RenderImageProps<IMarkerPhoto> & IExtraProps
|
||||
> = (props: RenderImageProps<IMarkerPhoto> & IExtraProps) => {
|
||||
const { configuration } = useContext(ConfigurationContext);
|
||||
const playSound = configuration?.interface.soundOnPreview ?? false;
|
||||
const showTitle = configuration?.interface.wallShowTitle ?? false;
|
||||
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
const height = Math.min(props.maxHeight, props.photo.height);
|
||||
const zoomFactor = height / props.photo.height;
|
||||
const width = props.photo.width * zoomFactor;
|
||||
|
||||
type style = Record<string, string | number | undefined>;
|
||||
var divStyle: style = {
|
||||
margin: props.margin,
|
||||
@@ -79,8 +87,8 @@ export const MarkerWallItem: React.FC<RenderImageProps<IMarkerPhoto>> = (
|
||||
role="button"
|
||||
style={{
|
||||
...divStyle,
|
||||
width: props.photo.width,
|
||||
height: props.photo.height,
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
>
|
||||
<ImagePreview
|
||||
@@ -90,8 +98,8 @@ export const MarkerWallItem: React.FC<RenderImageProps<IMarkerPhoto>> = (
|
||||
autoPlay={video}
|
||||
key={props.photo.key}
|
||||
src={props.photo.src}
|
||||
width={props.photo.width}
|
||||
height={props.photo.height}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={props.photo.alt}
|
||||
onMouseEnter={() => setActive(true)}
|
||||
onMouseLeave={() => setActive(false)}
|
||||
@@ -120,6 +128,7 @@ export const MarkerWallItem: React.FC<RenderImageProps<IMarkerPhoto>> = (
|
||||
|
||||
interface IMarkerWallProps {
|
||||
markers: GQL.SceneMarkerDataFragment[];
|
||||
zoomIndex: number;
|
||||
}
|
||||
|
||||
// HACK: typescript doesn't allow Gallery to accept a parameter for some reason
|
||||
@@ -152,11 +161,18 @@ function getDimensions(file?: IFile) {
|
||||
};
|
||||
}
|
||||
|
||||
const defaultTargetRowHeight = 250;
|
||||
const breakpointZoomHeights = [
|
||||
{ minWidth: 576, heights: [100, 120, 240, 360] },
|
||||
{ minWidth: 768, heights: [120, 160, 240, 480] },
|
||||
{ minWidth: 1200, heights: [120, 160, 240, 300] },
|
||||
{ minWidth: 1400, heights: [160, 240, 300, 480] },
|
||||
];
|
||||
|
||||
const MarkerWall: React.FC<IMarkerWallProps> = ({ markers }) => {
|
||||
const MarkerWall: React.FC<IMarkerWallProps> = ({ markers, zoomIndex }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const margin = 3;
|
||||
const direction = "row";
|
||||
|
||||
@@ -202,12 +218,41 @@ const MarkerWall: React.FC<IMarkerWallProps> = ({ markers }) => {
|
||||
return Math.round(columnCount);
|
||||
}
|
||||
|
||||
const renderImage = useCallback((props: RenderImageProps<IMarkerPhoto>) => {
|
||||
return <MarkerWallItem {...props} />;
|
||||
}, []);
|
||||
const targetRowHeight = useCallback(
|
||||
(containerWidth: number) => {
|
||||
let zoomHeight = 280;
|
||||
breakpointZoomHeights.forEach((e) => {
|
||||
if (containerWidth >= e.minWidth) {
|
||||
zoomHeight = e.heights[zoomIndex];
|
||||
}
|
||||
});
|
||||
return zoomHeight;
|
||||
},
|
||||
[zoomIndex]
|
||||
);
|
||||
|
||||
// set the max height as a factor of the targetRowHeight
|
||||
// this allows some images to be taller than the target row height
|
||||
// but prevents images from becoming too tall when there is a small number of items
|
||||
const maxHeightFactor = 1.3;
|
||||
|
||||
const renderImage = useCallback(
|
||||
(props: RenderImageProps<IMarkerPhoto>) => {
|
||||
return (
|
||||
<MarkerWallItem
|
||||
{...props}
|
||||
maxHeight={
|
||||
targetRowHeight(containerRef.current?.offsetWidth ?? 0) *
|
||||
maxHeightFactor
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[targetRowHeight]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="marker-wall">
|
||||
<div className="marker-wall" ref={containerRef}>
|
||||
{photos.length ? (
|
||||
<MarkerGallery
|
||||
photos={photos}
|
||||
@@ -216,7 +261,7 @@ const MarkerWall: React.FC<IMarkerWallProps> = ({ markers }) => {
|
||||
margin={margin}
|
||||
direction={direction}
|
||||
columns={columns}
|
||||
targetRowHeight={defaultTargetRowHeight}
|
||||
targetRowHeight={targetRowHeight}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -225,10 +270,12 @@ const MarkerWall: React.FC<IMarkerWallProps> = ({ markers }) => {
|
||||
|
||||
interface IMarkerWallPanelProps {
|
||||
markers: GQL.SceneMarkerDataFragment[];
|
||||
zoomIndex: number;
|
||||
}
|
||||
|
||||
export const MarkerWallPanel: React.FC<IMarkerWallPanelProps> = ({
|
||||
markers,
|
||||
zoomIndex,
|
||||
}) => {
|
||||
return <MarkerWall markers={markers} />;
|
||||
return <MarkerWall markers={markers} zoomIndex={zoomIndex} />;
|
||||
};
|
||||
|
||||
@@ -206,13 +206,7 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
|
||||
setCode(
|
||||
new ScrapeResult(dest.code, sources.find((s) => s.code)?.code, !dest.code)
|
||||
);
|
||||
setURL(
|
||||
new ScrapeResult(
|
||||
dest.urls,
|
||||
sources.find((s) => s.urls)?.urls,
|
||||
!dest.urls?.length
|
||||
)
|
||||
);
|
||||
setURL(new ScrapeResult(dest.urls, uniq(all.map((s) => s.urls).flat())));
|
||||
setDate(
|
||||
new ScrapeResult(dest.date, sources.find((s) => s.date)?.date, !dest.date)
|
||||
);
|
||||
@@ -311,8 +305,7 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
|
||||
.filter((s, index, a) => {
|
||||
// remove entries with duplicate endpoints
|
||||
return index === a.findIndex((ss) => ss.endpoint === s.endpoint);
|
||||
}),
|
||||
!dest.stash_ids.length
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -26,15 +26,23 @@ interface IScenePhoto {
|
||||
onError?: (photo: PhotoProps<IScenePhoto>) => void;
|
||||
}
|
||||
|
||||
export const SceneWallItem: React.FC<RenderImageProps<IScenePhoto>> = (
|
||||
props: RenderImageProps<IScenePhoto>
|
||||
) => {
|
||||
interface IExtraProps {
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
export const SceneWallItem: React.FC<
|
||||
RenderImageProps<IScenePhoto> & IExtraProps
|
||||
> = (props: RenderImageProps<IScenePhoto> & IExtraProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { configuration } = useContext(ConfigurationContext);
|
||||
const playSound = configuration?.interface.soundOnPreview ?? false;
|
||||
const showTitle = configuration?.interface.wallShowTitle ?? false;
|
||||
|
||||
const height = Math.min(props.maxHeight, props.photo.height);
|
||||
const zoomFactor = height / props.photo.height;
|
||||
const width = props.photo.width * zoomFactor;
|
||||
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
type style = Record<string, string | number | undefined>;
|
||||
@@ -72,8 +80,8 @@ export const SceneWallItem: React.FC<RenderImageProps<IScenePhoto>> = (
|
||||
role="button"
|
||||
style={{
|
||||
...divStyle,
|
||||
width: props.photo.width,
|
||||
height: props.photo.height,
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
>
|
||||
<ImagePreview
|
||||
@@ -83,8 +91,8 @@ export const SceneWallItem: React.FC<RenderImageProps<IScenePhoto>> = (
|
||||
autoPlay={video}
|
||||
key={props.photo.key}
|
||||
src={props.photo.src}
|
||||
width={props.photo.width}
|
||||
height={props.photo.height}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={props.photo.alt}
|
||||
onMouseEnter={() => setActive(true)}
|
||||
onMouseLeave={() => setActive(false)}
|
||||
@@ -126,16 +134,28 @@ function getDimensions(s: GQL.SlimSceneDataFragment) {
|
||||
interface ISceneWallProps {
|
||||
scenes: GQL.SlimSceneDataFragment[];
|
||||
sceneQueue?: SceneQueue;
|
||||
zoomIndex: number;
|
||||
}
|
||||
|
||||
// HACK: typescript doesn't allow Gallery to accept a parameter for some reason
|
||||
const SceneGallery = Gallery as unknown as GalleryI<IScenePhoto>;
|
||||
|
||||
const defaultTargetRowHeight = 250;
|
||||
const breakpointZoomHeights = [
|
||||
{ minWidth: 576, heights: [100, 120, 240, 360] },
|
||||
{ minWidth: 768, heights: [120, 160, 240, 480] },
|
||||
{ minWidth: 1200, heights: [120, 160, 240, 300] },
|
||||
{ minWidth: 1400, heights: [160, 240, 300, 480] },
|
||||
];
|
||||
|
||||
const SceneWall: React.FC<ISceneWallProps> = ({ scenes, sceneQueue }) => {
|
||||
const SceneWall: React.FC<ISceneWallProps> = ({
|
||||
scenes,
|
||||
sceneQueue,
|
||||
zoomIndex,
|
||||
}) => {
|
||||
const history = useHistory();
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const margin = 3;
|
||||
const direction = "row";
|
||||
|
||||
@@ -186,12 +206,41 @@ const SceneWall: React.FC<ISceneWallProps> = ({ scenes, sceneQueue }) => {
|
||||
return Math.round(columnCount);
|
||||
}
|
||||
|
||||
const renderImage = useCallback((props: RenderImageProps<IScenePhoto>) => {
|
||||
return <SceneWallItem {...props} />;
|
||||
}, []);
|
||||
const targetRowHeight = useCallback(
|
||||
(containerWidth: number) => {
|
||||
let zoomHeight = 280;
|
||||
breakpointZoomHeights.forEach((e) => {
|
||||
if (containerWidth >= e.minWidth) {
|
||||
zoomHeight = e.heights[zoomIndex];
|
||||
}
|
||||
});
|
||||
return zoomHeight;
|
||||
},
|
||||
[zoomIndex]
|
||||
);
|
||||
|
||||
// set the max height as a factor of the targetRowHeight
|
||||
// this allows some images to be taller than the target row height
|
||||
// but prevents images from becoming too tall when there is a small number of items
|
||||
const maxHeightFactor = 1.3;
|
||||
|
||||
const renderImage = useCallback(
|
||||
(props: RenderImageProps<IScenePhoto>) => {
|
||||
return (
|
||||
<SceneWallItem
|
||||
{...props}
|
||||
maxHeight={
|
||||
targetRowHeight(containerRef.current?.offsetWidth ?? 0) *
|
||||
maxHeightFactor
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[targetRowHeight]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="scene-wall">
|
||||
<div className={`scene-wall`} ref={containerRef}>
|
||||
{photos.length ? (
|
||||
<SceneGallery
|
||||
photos={photos}
|
||||
@@ -200,7 +249,7 @@ const SceneWall: React.FC<ISceneWallProps> = ({ scenes, sceneQueue }) => {
|
||||
margin={margin}
|
||||
direction={direction}
|
||||
columns={columns}
|
||||
targetRowHeight={defaultTargetRowHeight}
|
||||
targetRowHeight={targetRowHeight}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -210,11 +259,15 @@ const SceneWall: React.FC<ISceneWallProps> = ({ scenes, sceneQueue }) => {
|
||||
interface ISceneWallPanelProps {
|
||||
scenes: GQL.SlimSceneDataFragment[];
|
||||
sceneQueue?: SceneQueue;
|
||||
zoomIndex: number;
|
||||
}
|
||||
|
||||
export const SceneWallPanel: React.FC<ISceneWallPanelProps> = ({
|
||||
scenes,
|
||||
sceneQueue,
|
||||
zoomIndex,
|
||||
}) => {
|
||||
return <SceneWall scenes={scenes} sceneQueue={sceneQueue} />;
|
||||
return (
|
||||
<SceneWall scenes={scenes} sceneQueue={sceneQueue} zoomIndex={zoomIndex} />
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user