Compare commits

...

91 Commits

Author SHA1 Message Date
WithoutPants
7716c4dd87 Update changelog 2025-11-06 16:55:40 +11:00
WithoutPants
2925325e68 Fix contents not loading in filter sidebar (#6240) 2025-11-06 16:54:53 +11:00
WithoutPants
beee37bc38 Codeberg weblate (#6235)
* Translated using Weblate (Bulgarian)

Currently translated at 25.0% (305 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/bg/

* Translated using Weblate (Dutch)

Currently translated at 77.1% (940 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/nl/

* Translated using Weblate (Turkish)

Currently translated at 95.9% (1170 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/tr/

---------

Co-authored-by: theqwertyqwert <theqwertyqwert@noreply.codeberg.org>
Co-authored-by: callmenoodles <callmenoodles@noreply.codeberg.org>
Co-authored-by: slickdaddy <slickdaddy@noreply.codeberg.org>
2025-11-06 11:07:15 +11:00
WithoutPants
9be0cc3210 Update changelog 2025-11-06 10:46:37 +11:00
WithoutPants
f2a787a2ba Add (hidden) pagination to list results header (#6234) 2025-11-06 10:45:57 +11:00
Gykes
6cace4ff88 Update parser to accept groups (#6228) 2025-11-06 09:53:43 +11:00
DogmaDragon
fa2fd31ac7 Update library section in Configuration.md for clarity (#6232) 2025-11-06 08:24:33 +11:00
WithoutPants
1b2b4c5221 Fix panic when scraping with unknown field (#6220)
* Fix URL in group scraper causing panic
* Return error instead of panicking on unknown field
2025-10-31 19:54:35 +11:00
WithoutPants
336fa3b70e Save sidebar state (#6217)
* Save sidebar section open state in browser history state

This means that state is saved when going back, but not when navigating to the scenes page from elsewhere.
2025-10-31 15:21:43 +11:00
WithoutPants
299e1ac1f9 Scene list toolbar style update (#6215)
* Add saved filter button to toolbar
* Rearrange and add portal target
* Only overlap sidebar on sm viewports
* Hide dropdown button on smaller viewports when sidebar open
* Center operations during selection
* Restyle results header
* Add classname for sidebar pane content
* Move sidebar toggle to left during scene selection
2025-10-31 14:29:01 +11:00
WithoutPants
fb7bd89834 Fix update loop in Group Sub Groups panel (#6212)
* Fix location equality testing causing update loop
* Move defaultFilter out of component
* Fix add sub groups dialog dropdown render issue
2025-10-29 11:33:20 +11:00
WithoutPants
f04be76224 Don't trim query string from decoded URL params (#6211) 2025-10-29 11:13:46 +11:00
WithoutPants
db79cf9bb1 Increase number of pages in pagination dropdown to 1000 (#6207) 2025-10-29 11:13:29 +11:00
WithoutPants
90baa31ee3 Hide zoom slider in xs viewports (#6206)
The zoom slider doesn't function in this viewport so it shouldn't be shown.
2025-10-29 11:13:13 +11:00
WithoutPants
9b8300e882 Only scroll edit filter dialog when clicking filter tag (#6205) 2025-10-29 11:12:57 +11:00
WithoutPants
d70ff551d4 Replace "movie" with "group" in scene is missing criterion (#6204)
* Add support for "group" value in scene is-missing filter criterion
* Replace movie with group in scene is missing criterion
2025-10-29 11:12:42 +11:00
WithoutPants
1dccecc39c Go to list page if deleting with empty history (#6203) 2025-10-29 11:12:25 +11:00
WithoutPants
648875995c Fix play random not using effective filter (#6202) 2025-10-29 11:12:00 +11:00
WithoutPants
96b5a9448c Fix source.StashBoxEndpoint reference causing nil deref (#6201) 2025-10-29 11:11:42 +11:00
WithoutPants
fda97e7f6c Return if primary file failed to load (#6200) 2025-10-29 11:11:21 +11:00
WithoutPants
869cbd496b Update changelog 2025-10-22 12:49:27 +11:00
WithoutPants
5049d6e5c9 Fix scene list table styling issues (#6169)
* Reduce z-index of table list header
* Set better max-height for scene list table
2025-10-22 12:48:39 +11:00
WithoutPants
98df51755e Fix column layout image wall issues (#6168) 2025-10-22 12:21:04 +11:00
WithoutPants
947a17355c Fix UI loop when sorting by random without seed (#6167) 2025-10-22 11:31:42 +11:00
WithoutPants
71e4071871 Encode credentials during login (#6163) 2025-10-21 19:04:44 +11:00
WithoutPants
a6778d7d22 Add discourse links to manual 2025-10-21 10:34:02 +11:00
WithoutPants
415e88808f Codeberg weblate (#6159)
* Translated using Weblate (Bulgarian)

Currently translated at 11.3% (138 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/bg/

* Translated using Weblate (Bulgarian)

Currently translated at 22.3% (272 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/bg/

---------

Co-authored-by: theqwertyqwert <theqwertyqwert@noreply.codeberg.org>
2025-10-21 08:43:59 +11:00
WithoutPants
d0283fe330 Update changelog 2025-10-21 08:21:53 +11:00
WithoutPants
c162c3843d Add timeout to ffmpeg hardware tests (#6154) 2025-10-21 08:13:42 +11:00
theqwertyqwert
cb6c53deb5 Update marker background color logic to use primaryTag name instead of title (#6141) 2025-10-20 13:00:06 +11:00
smith113-p
97ca5a28d3 Use the merged stash IDs by default (#6152) 2025-10-20 12:59:36 +11:00
smith113-p
cee68ab87b Merge URLs when merging scenes (#6151) 2025-10-20 12:58:26 +11:00
fancydancers
c6bf20dd77 install python packages system-wide (#6120) 2025-10-20 10:55:11 +11:00
gregpetersonanon
914bbfc164 Prevent scanner from failing when reading file info (#6123) 2025-10-20 10:54:26 +11:00
feederbox826
060daef0b7 add gql interceptor note to changelog #5964 (#6148) 2025-10-17 11:53:43 +11:00
WithoutPants
de5a9129b3 Use SafeMove when moving backup database (#6147) 2025-10-17 08:17:15 +11:00
WithoutPants
13953c2fbd Codeberg weblate update (#6145)
* Translated using Weblate (Indonesian)

Currently translated at 44.8% (537 of 1198 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/id/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 20.6% (247 of 1198 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/

* Translated using Weblate (Japanese)

Currently translated at 83.9% (1006 of 1198 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ja/

* Translated using Weblate (Turkish)

Currently translated at 92.2% (1105 of 1198 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/tr/

* Translated using Weblate (French)

Currently translated at 100.0% (1205 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1205 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/

* Translated using Weblate (German)

Currently translated at 99.9% (1204 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1205 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Translated using Weblate (Norwegian Nynorsk)

Currently translated at 15.6% (188 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/nn/

* Translated using Weblate (Dutch)

Currently translated at 70.8% (854 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/nl/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1205 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/

* Added translation using Weblate (Urdu)

* Translated using Weblate (Czech)

Currently translated at 100.0% (1205 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/cs/

* Translated using Weblate (Turkish)

Currently translated at 94.5% (1139 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/tr/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1205 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Catalan)

Currently translated at 37.1% (448 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ca/

* Translated using Weblate (Dutch)

Currently translated at 71.0% (856 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/nl/

* Translated using Weblate (Vietnamese)

Currently translated at 22.5% (272 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/vi/

* Translated using Weblate (English (United States))

Currently translated at 28.4% (343 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/en_US/

* Translated using Weblate (Russian)

Currently translated at 95.9% (1156 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ru/

* Translated using Weblate (Japanese)

Currently translated at 84.4% (1018 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ja/

* Translated using Weblate (Vietnamese)

Currently translated at 34.1% (412 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/vi/

* Translated using Weblate (Vietnamese)

Currently translated at 56.2% (678 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/vi/

* Translated using Weblate (Urdu)

Currently translated at 0.2% (3 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ur/

* Translated using Weblate (French)

Currently translated at 100.0% (1205 of 1205 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/

* Translated using Weblate (French)

Currently translated at 100.0% (1208 of 1208 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 21.2% (257 of 1208 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1208 of 1208 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Translated using Weblate (French)

Currently translated at 100.0% (1209 of 1209 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1209 of 1209 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 23.2% (281 of 1209 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/

* Translated using Weblate (Dutch)

Currently translated at 72.7% (880 of 1209 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/nl/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1209 of 1209 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 99.1% (1199 of 1209 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hant/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 24.3% (294 of 1209 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 24.3% (294 of 1209 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/

* Translated using Weblate (Latvian)

Currently translated at 11.9% (145 of 1209 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/lv/

* Translated using Weblate (Romanian)

Currently translated at 36.0% (436 of 1209 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ro/

* Translated using Weblate (Czech)

Currently translated at 100.0% (1209 of 1209 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/cs/

* Translated using Weblate (French)

Currently translated at 100.0% (1213 of 1213 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Japanese)

Currently translated at 84.3% (1023 of 1213 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ja/

* Translated using Weblate (French)

Currently translated at 100.0% (1215 of 1215 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1215 of 1215 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1215 of 1215 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (French)

Currently translated at 100.0% (1216 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1216 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1216 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Vietnamese)

Currently translated at 59.6% (725 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/vi/

* Translated using Weblate (Vietnamese)

Currently translated at 59.6% (725 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/vi/

* Translated using Weblate (German)

Currently translated at 99.0% (1204 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/

* Translated using Weblate (Vietnamese)

Currently translated at 60.0% (730 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/vi/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1216 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/

* Translated using Weblate (Turkish)

Currently translated at 96.0% (1168 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/tr/

* Translated using Weblate (Polish)

Currently translated at 82.3% (1001 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/pl/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 25.0% (304 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/

* Translated using Weblate (German)

Currently translated at 100.0% (1216 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/

* Translated using Weblate (Czech)

Currently translated at 100.0% (1216 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/cs/

* Translated using Weblate (English (United States))

Currently translated at 28.0% (341 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/en_US/

* Translated using Weblate (Russian)

Currently translated at 97.7% (1189 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ru/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1216 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/

* Translated using Weblate (Russian)

Currently translated at 100.0% (1216 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ru/

* Translated using Weblate (Persian)

Currently translated at 2.5% (31 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fa/

* Translated using Weblate (Dutch)

Currently translated at 75.7% (921 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/nl/

* Translated using Weblate (Vietnamese)

Currently translated at 60.2% (733 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/vi/

* Translated using Weblate (Japanese)

Currently translated at 84.2% (1024 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ja/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 94.6% (1151 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (1216 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (1216 of 1216 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hant/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1219 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/

* Translated using Weblate (French)

Currently translated at 100.0% (1219 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Added translation using Weblate (Lithuanian)

* Translated using Weblate (Korean)

Currently translated at 100.0% (1219 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Lithuanian)

Currently translated at 8.6% (105 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/lt/

* Translated using Weblate (German)

Currently translated at 100.0% (1219 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/

* Translated using Weblate (Croatian)

Currently translated at 20.6% (252 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/hr/

* Translated using Weblate (Czech)

Currently translated at 100.0% (1219 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/cs/

* Translated using Weblate (Vietnamese)

Currently translated at 64.5% (787 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/vi/

* Translated using Weblate (Indonesian)

Currently translated at 47.1% (575 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/id/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1219 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Translated using Weblate (Indonesian)

Currently translated at 49.6% (605 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/id/

* Translated using Weblate (Italian)

Currently translated at 75.7% (924 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/it/

* Translated using Weblate (Finnish)

Currently translated at 80.3% (979 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fi/

* Translated using Weblate (Spanish)

Currently translated at 96.8% (1180 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/

* Translated using Weblate (Urdu)

Currently translated at 0.8% (10 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ur/

* Translated using Weblate (Czech)

Currently translated at 100.0% (1219 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/cs/

* Translated using Weblate (Turkish)

Currently translated at 95.8% (1168 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/tr/

* Added translation using Weblate (Bulgarian)

* Translated using Weblate (Finnish)

Currently translated at 80.5% (982 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fi/

* Translated using Weblate (Bulgarian)

Currently translated at 4.1% (51 of 1219 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/bg/

* Correct new locale filenames

* Update language options

* Correct error in de-DE

* Filter en-US to only different strings

---------

Co-authored-by: fafafafa <fafafafa@noreply.codeberg.org>
Co-authored-by: boy3satiable <boy3satiable@noreply.codeberg.org>
Co-authored-by: ynclt <ynclt@noreply.codeberg.org>
Co-authored-by: slickdaddy <slickdaddy@noreply.codeberg.org>
Co-authored-by: doodoo <doodoo@noreply.codeberg.org>
Co-authored-by: AlpacaSerious <alpacaserious@noreply.codeberg.org>
Co-authored-by: tzuuuss <tzuuuss@noreply.codeberg.org>
Co-authored-by: wql219 <wanqinglin219@hotmail.com>
Co-authored-by: throbbing <throbbing@noreply.codeberg.org>
Co-authored-by: youri <youri@noreply.codeberg.org>
Co-authored-by: Zesty6249 <zesty6249@noreply.codeberg.org>
Co-authored-by: Lambert99 <lambert99@noreply.codeberg.org>
Co-authored-by: NymeriaCZ <nymeriacz@noreply.codeberg.org>
Co-authored-by: yec <yec@noreply.codeberg.org>
Co-authored-by: burrisol <burrisol@noreply.codeberg.org>
Co-authored-by: Cindicent <cindicent@noreply.codeberg.org>
Co-authored-by: nitromelon <nitromelon@noreply.codeberg.org>
Co-authored-by: boxcrunch <boxcrunch@noreply.codeberg.org>
Co-authored-by: Fl0master1337 <fl0master1337@noreply.codeberg.org>
Co-authored-by: tobakumap <tobakumap@noreply.codeberg.org>
Co-authored-by: dragoncrazy2011 <dragoncrazy2011@noreply.codeberg.org>
Co-authored-by: CrypticGlycolic <crypticglycolic@noreply.codeberg.org>
Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: bwithnewcast <bwithnewcast@noreply.codeberg.org>
Co-authored-by: COTMO <cotmo@noreply.codeberg.org>
Co-authored-by: danny60718 <danny60718@noreply.codeberg.org>
Co-authored-by: noTranslator <notranslator@noreply.codeberg.org>
Co-authored-by: ℂ𝕠𝕠𝕠𝕝 (𝕘𝕚𝕥𝕙𝕦𝕓.𝕔𝕠𝕞/ℂ𝕠𝕠𝕠𝕝) <coool@noreply.codeberg.org>
Co-authored-by: noqqyg <noqqyg@noreply.codeberg.org>
Co-authored-by: DJSweder <djsweder@noreply.codeberg.org>
Co-authored-by: m4549071758 <m4549071758@noreply.codeberg.org>
Co-authored-by: lugged9922 <lugged9922@noreply.codeberg.org>
Co-authored-by: phanh <phanh@noreply.codeberg.org>
Co-authored-by: krohnoz <krohnoz@noreply.codeberg.org>
Co-authored-by: AngryPikachu_025 <angrypikachu_025@noreply.codeberg.org>
Co-authored-by: certivian <certivian@noreply.codeberg.org>
Co-authored-by: Marly21 <marly21@noreply.codeberg.org>
Co-authored-by: OtterBotSociety <otterbotsociety@noreply.codeberg.org>
Co-authored-by: Schmitd <schmitd@noreply.codeberg.org>
Co-authored-by: mmovahedi <mmovahedi@noreply.codeberg.org>
Co-authored-by: DNArjen <dnarjen@noreply.codeberg.org>
Co-authored-by: nguyenhuy158 <nguyenhuy158@noreply.codeberg.org>
Co-authored-by: furinkazan <furinkazan@noreply.codeberg.org>
Co-authored-by: Phrotan <phrotan@noreply.codeberg.org>
Co-authored-by: TWNO1 <twno1@noreply.codeberg.org>
Co-authored-by: Troink <troink@noreply.codeberg.org>
Co-authored-by: zo3n <zo3n@noreply.codeberg.org>
Co-authored-by: manhtuanphoto <manhtuanphoto@noreply.codeberg.org>
Co-authored-by: om_Yanto <om_yanto@noreply.codeberg.org>
Co-authored-by: shanpai <shanpai@noreply.codeberg.org>
Co-authored-by: Uskonalle <uskonalle@noreply.codeberg.org>
Co-authored-by: gallegonovato <gallegonovato@noreply.codeberg.org>
Co-authored-by: jirkacapek123 <jirkacapek123@noreply.codeberg.org>
Co-authored-by: theqwertyqwert <theqwertyqwert@noreply.codeberg.org>
Co-authored-by: Ricky-Tigg <ricky-tigg@noreply.codeberg.org>
2025-10-16 18:31:33 +11:00
WithoutPants
479ad49e81 Add 0.29 release notes (#6144)
* Add 0.29 release notes
* Add optional release notes to changelog entries
2025-10-16 14:45:29 +11:00
WithoutPants
ce4b86daf5 Fix tag order on details pages (#6143)
* Fix related tag order
* Fix unit tests
2025-10-16 13:15:09 +11:00
WithoutPants
0c5285c949 Add 0.29 changelog 2025-10-15 17:55:05 +11:00
WithoutPants
fbba4f06a9 Correct movies to groups in default menu items (#6140)
Fixes unnecessary config migration artifact in new systems
2025-10-15 16:53:08 +11:00
WithoutPants
e1b3b33c24 Correctly load generate options when generating from tasks page (#6139) 2025-10-15 16:52:54 +11:00
underprovisioned
eb816d2e4f Sort duplicate scene groups by total filesize descending (#6133) 2025-10-15 16:52:40 +11:00
WithoutPants
05e2fb26be Fix setup wizard issues (#6138)
* Correct paths in confirm step
* Maintain paths when going back from confirm step
2025-10-15 16:31:52 +11:00
WithoutPants
7b182ac04b Vacuum into database directory then move file if backup dir different (#6137)
If the backup directory is not the same directory as the database, then vacuum into the same directory then move it to its destination. This is to prevent issues vacuuming over a network share.
2025-10-15 16:30:06 +11:00
WithoutPants
2e8bc3536f Null check image visual_files (#6136) 2025-10-15 16:29:51 +11:00
WithoutPants
6d76fe690b Add padding to tag links (#6129) 2025-10-13 13:13:58 +11:00
WithoutPants
d3f6301101 Use natural sort for related tags (#6128) 2025-10-13 13:13:45 +11:00
WithoutPants
72c9c436be Fix groups not transferring when merging tags (#6127)
* Add test for group when merging tags
* Fix groups not reallocated when merging tags
2025-10-13 13:13:23 +11:00
fancydancers
2ed9e5332d add content-disposition filename header to streams (#6119)
* add content-disposition filename header to streams
* Fix filename generation on windows
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2025-10-09 13:35:11 +11:00
WithoutPants
c5bad48ece Scene list cleanup (#6104)
* Generalise and cleanup list toolbar
* Generalise ListResultsHeader
* Fix padding on sub-pages
2025-10-06 07:45:36 +11:00
WithoutPants
af76f4a24a Prevent input field from focusing on touch devices rather than mobile (#6105) 2025-10-06 07:44:59 +11:00
xtc1337
15bf28d5be Adding the ability to support different Haptic Devices (#5856)
* refactored `Interactive` class to allow more HapticDevice devices
* simplified api hooks
* update creation of `interactive` to pass `stashConfig`
* updated UIPluginApi to mention `PluginApi.InteractiveUtils`
2025-09-25 15:27:58 +10:00
sezzim
c9ca40152f Show gallery cover on the edit panel (#5935) 2025-09-25 15:26:24 +10:00
WithoutPants
724d438721 Wall item height fix (#6101)
* Fix scene wall item height with fewer items
* Fix for marker wall
* Fix for image wall
* Provide some allowance for items to go over height
2025-09-25 15:26:01 +10:00
DogmaDragon
acddf97771 Refactor issue templates: replace markdown files with YAML configurations for bug reports, feature requests (#6102) 2025-09-25 15:20:30 +10:00
WithoutPants
823ed346c1 Add separate sidebar toggle button (#6077)
* Move sidebar toggle to right. Change icon
* Show sidebar button on selection
* Fix clicking toggle cycling visibility on smaller views
* Show more tags component when cutoff == 0
* Hide filter/filter icon buttons in certain situations
* Move sidebar toggle to left on xl viewports
2025-09-24 11:27:08 +10:00
WithoutPants
3bb771a149 Add search term filter tag to scene list filter tags (#6095)
* Add search term to filter tags on scene list page

Clicking on the tag selects all on the search term input. Clicking on the x erases it.

* Ensure clear criteria maintains consistent behaviour on other pages
* Hide search term tag when input is visible
2025-09-24 10:45:09 +10:00
WithoutPants
793a5f826e Edit filter load save (#6092)
* Add load/save buttons to edit filter dialog
* Add title to save filter dialog
* Change ExistingSavedFilterList parameters
* Add title to load/save buttons
2025-09-18 12:09:19 +10:00
WithoutPants
8012f2eb8a Add search term input to edit filter dialog (#6082) 2025-09-17 15:08:47 +10:00
WithoutPants
98716d5568 Show search field always (#6079) 2025-09-17 14:41:48 +10:00
feederbox826
edcc4e8968 Fix descender line-height (#6087) 2025-09-16 14:25:22 +10:00
Otter Bot Society
12c4e1f61c Treat images with no exif metadata as well-oriented (#6006) 2025-09-09 16:48:16 +10:00
WithoutPants
cc97e96eaa Add wall zoom functionality (#6011)
* Show zoom slider when wall view active
* Add zoom functionality to scene wall
* Add zoom functionality to image wall
* Add zoom functionality to gallery wall
* Add zoom functionality for marker wall
2025-09-09 16:45:29 +10:00
WithoutPants
b1883f3df5 Add gallery link to image lightbox (#6012) 2025-09-09 16:44:51 +10:00
gregpetersonanon
fd36c0fac7 Allow scan to continue when encountering an error (#6073) 2025-09-09 15:10:13 +10:00
feederbox826
b5b207c940 remove ruby and faraday gem (#6020) 2025-09-09 15:07:00 +10:00
feederbox826
c0ba119ebf exclude empty regex exclude (#6023) 2025-09-09 15:04:39 +10:00
feederbox826
e23bdfa204 Add media hardware key support (#6031) 2025-09-09 15:03:55 +10:00
WithoutPants
14be3c24ff Revert "Search term filter tag on scene list (#5991)" (#6003)
This reverts commit 21ee88b149.
2025-07-08 13:12:46 +10:00
WithoutPants
21ee88b149 Search term filter tag on scene list (#5991)
* Add search term to filter tags on scene list page

Clicking on the tag selects all on the search term input. Clicking on the x erases it.

* Ensure clear criteria maintains consistent behaviour on other pages
2025-07-08 10:41:33 +10:00
WithoutPants
dd9a1f5ce3 Apply filter hook to subpage sidebar filters (#5995)
* Apply filterHook to sidebar filters on subpages
* Hide studio filter in studio subpage
2025-07-08 10:41:12 +10:00
WithoutPants
694675470e Map parent studio after creation (#5996) 2025-07-08 10:40:54 +10:00
WithoutPants
642b0f2291 Add missing keybinds to scene list (#5994) 2025-07-03 14:41:06 +10:00
WithoutPants
1b3a8acab2 Bring back select all/none to scene list (#5993) 2025-07-03 13:07:37 +10:00
WithoutPants
108c0c7de5 Use /plugins instead of /plugin in testReact 2025-07-03 10:40:51 +10:00
WithoutPants
dcfb3b7d37 Throw error on re-registering component only if in prod environment (#5990)
This was causing an error when hot-reloading components, meaning that the components would not be refreshed.
2025-07-02 17:07:01 +10:00
WithoutPants
d98e9c6618 Show filter tags in scene list toolbar (#5969)
* Add filter tags to toolbar
* Show overflow control if filter tags overflow
* Remove second set of filter tags from top of page
* Add border around filter area
2025-07-02 16:34:40 +10:00
QxxxGit
f01f95ddfb Organize UIPluginApi.md docs and pluginApi.d.ts (#5971)
* Organized alphabetically and removed duplicate Setting and TabTitleCounter
* Organized components alphabetically
* Add missing PerformerDetailsPanel and PerformerDetailsPanel.DetailGroup
* Add missing SceneFileInfoPanel component
* Add missing MainNavBar.MenuItems and MainNavBar.UtilityItems in docs
2025-07-01 13:48:16 +10:00
WithoutPants
3a232b1d6c Pagination styling (#5973)
* Raise pagination slightly to avoid occlusion from link bar
* Add shadow to pagination
2025-06-30 07:53:33 +10:00
WithoutPants
6f4920cb81 Update custom css links and replace plex theme link with themes link (#5976) 2025-06-30 07:53:08 +10:00
WithoutPants
61ab6ce6bd Fix funscript parsing issues (#5978)
* Accept floating point numbers for at field in funscript
* Ignore type of script version field
* Write rounded ints for csv
2025-06-30 07:52:53 +10:00
WithoutPants
7215b6e918 Ensure tmp dir is created before creating temp file (#5977) 2025-06-30 07:52:32 +10:00
WithoutPants
bd8ec8cb83 Don't update stash ids when scraping from stash-box (#5975) 2025-06-30 07:52:12 +10:00
WithoutPants
429022a468 Handle marshalling scraped movie to group (#5974) 2025-06-30 07:51:53 +10:00
WithoutPants
5323d68d3d Add graphql playground link to tools panel (#5807)
* Add graphql playground link to tools panel
* Move to separate section
2025-06-27 16:26:03 +10:00
DogmaDragon
3af472d3b2 Fix typo in Plugins documentation for clarity (#5972) 2025-06-27 12:54:44 +10:00
WithoutPants
7eff7f02d0 Add findFolder and findFolders queries to graphql schema (#5965)
* Add findFolder and findFolders queries to graphql schema
* Add zip file criterion to file and folder queries
2025-06-26 15:48:29 +10:00
WithoutPants
661d2f64bb Make path criterion default modifier includes instead of equals (#5968) 2025-06-26 15:47:45 +10:00
WithoutPants
d0a7b09bf3 Scene list toolbar (#5938)
* Add sticky query toolbar to scenes page
* Filter button accept count instead of filter
* Add play button
* Add create button functionality. Remove new scene button from navbar
* Separate toolbar into component
* Separate sort by select component
* Don't show filter tags control if no criteria
* Add utility setter methods to ListFilterModel
* Add results header with display options
* Use css for filter tag styling
* Add className to OperationDropdown and Item
* Increase size of sidebar controls on mobile
2025-06-26 09:17:22 +10:00
DogmaDragon
27bc6c8fca Update captions documentation (#5967)
* Update captions documentation to clarify file location and naming conventions
* Clarify naming convention
2025-06-26 07:33:55 +10:00
179 changed files with 8236 additions and 1365 deletions

View File

@@ -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
View 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
View 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.

View File

@@ -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. -->

View File

@@ -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.

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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!]!
}

View File

@@ -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

View 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
}

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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
View 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
}

View File

@@ -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)

View File

@@ -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"`

View File

@@ -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"`

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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
}

View File

@@ -321,7 +321,7 @@ type FileStore struct {
func NewFileStore() *FileStore {
return &FileStore{
repository: repository{
tableName: sceneTable,
tableName: fileTable,
idColumn: idColumn,
},

View File

@@ -68,6 +68,7 @@ func (qb *fileFilterHandler) criterionHandler() criterionHandler {
&timestampCriterionHandler{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 {

View File

@@ -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))
}
})
}

View File

@@ -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
View 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"),
&timestampCriterionHandler{folderFilter.ModTime, "folders.mod_time", nil},
qb.parentFolderCriterionHandler(folderFilter.ParentFolder),
qb.zipFileCriterionHandler(folderFilter.ZipFile),
qb.galleryCountCriterionHandler(folderFilter.GalleryCount),
&timestampCriterionHandler{folderFilter.CreatedAt, "folders.created_at", nil},
&timestampCriterionHandler{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...)
}
}
}

View 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))
}
})
}
}

View File

@@ -155,7 +155,7 @@ var (
},
fkColumn: "tag_id",
foreignTable: tagTable,
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
orderBy: tagTableSortSQL,
},
images: joinRepository{
repository: repository{

View File

@@ -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,

View File

@@ -122,7 +122,7 @@ var (
},
fkColumn: tagIDColumn,
foreignTable: tagTable,
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
orderBy: tagTableSortSQL,
},
}
)

View File

@@ -177,7 +177,7 @@ var (
},
fkColumn: tagIDColumn,
foreignTable: tagTable,
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
orderBy: tagTableSortSQL,
},
}
)

View File

@@ -189,7 +189,7 @@ var (
},
fkColumn: tagIDColumn,
foreignTable: tagTable,
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
orderBy: tagTableSortSQL,
},
stashIDs: stashIDRepository{
repository{

View File

@@ -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,

View File

@@ -201,7 +201,7 @@ var (
},
fkColumn: tagIDColumn,
foreignTable: tagTable,
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
orderBy: tagTableSortSQL,
},
performers: joinRepository{
repository: repository{

View File

@@ -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":

View File

@@ -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")
}

View File

@@ -133,7 +133,7 @@ var (
},
fkColumn: tagIDColumn,
foreignTable: tagTable,
orderBy: "COALESCE(tags.sort_name, tags.name) ASC",
orderBy: tagTableSortSQL,
},
}
)

View File

@@ -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{

View File

@@ -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)

View File

@@ -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())

View File

@@ -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>

View File

@@ -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>
))}

View File

@@ -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");
}
}

View File

@@ -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>

View File

@@ -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} />
))}

View File

@@ -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",

View File

@@ -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;

View File

@@ -114,6 +114,7 @@ export const AddSubGroupsDialog: React.FC<IListOperationProps> = (
onUpdate={(input) => setEntries(input)}
excludeIDs={excludeIDs}
filterHook={filterHook}
menuPortalTarget={document.body}
/>
</Form>
</ModalComponent>

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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");
}
}

View File

@@ -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}
/>
);
}

View File

@@ -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}
/>

View File

@@ -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>
</>

View File

@@ -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"

View File

@@ -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

View File

@@ -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}
/>
</>
);

View File

@@ -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 ? (

View File

@@ -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) => {

View File

@@ -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);

View File

@@ -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;

View File

@@ -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>
</>

View File

@@ -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}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}

View File

@@ -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;

View 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>
);
};

View 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>
);
};

View File

@@ -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}

View File

@@ -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 = [];

View File

@@ -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>
);
};

View File

@@ -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;
}
}
}

View File

@@ -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,

View File

@@ -103,7 +103,6 @@ const allMenuItems: IMenuItem[] = [
href: "/scenes",
icon: faPlayCircle,
hotkey: "g s",
userCreatable: true,
},
{
name: "images",

View File

@@ -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) {

View File

@@ -466,7 +466,6 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
setScraper(undefined);
} else {
setScrapedPerformer(result);
updateStashIDs(performerResult.remote_site_id);
}
}

View File

@@ -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: {

View File

@@ -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

View File

@@ -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;

View File

@@ -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();

View File

@@ -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");
}
}

View File

@@ -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>
);

View File

@@ -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}
/>
);
}

View File

@@ -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} />;
};

View File

@@ -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
})
)
);

View File

@@ -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