1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-03-07 13:39:58 -05:00

Compare commits

..

6 Commits

Author SHA1 Message Date
Mark McDowall
974c4a601b Show save error in UI 2024-09-06 20:53:55 -07:00
Mark McDowall
9549038121 Convert QualityDefinitionLimits to static 2024-09-06 20:35:19 -07:00
Robert Dailey
2f193ac58a Add API validation and tests for quality limits 2024-09-06 09:32:11 -05:00
Robert Dailey
e893ca4f1c Support validation of collections in RestController 2024-09-06 09:32:11 -05:00
Robert Dailey
039d7775ed feat: Shift quality definition limits management to the backend
This update moves the minimum, maximum, and preferred quality limits to
the backend, accessible via the new `/qualitydefinition/limits`
endpoint. This change improves support for unofficial Sonarr API clients
and enables a more flexible frontend.
2024-09-06 09:32:11 -05:00
Robert Dailey
87bd5e62f2 Add .idea directory to gitignore
For users of the Jetbrains IDEs, the `.idea` directory isn't strictly
necessary for version control. It's better to ignore it than tie a repo
to specific tooling.
2024-09-06 09:32:11 -05:00
744 changed files with 19015 additions and 33414 deletions

View File

@@ -22,7 +22,7 @@ env:
FRAMEWORK: net6.0
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
SONARR_MAJOR_VERSION: 4
VERSION: 4.0.16
VERSION: 4.0.9
jobs:
backend:

View File

@@ -1,29 +0,0 @@
name: 'Support Requests'
on:
issues:
types: [labeled, unlabeled, reopened]
permissions:
issues: write
jobs:
action:
runs-on: ubuntu-latest
if: github.repository == 'Sonarr/Sonarr'
steps:
- uses: dessant/support-requests@v4
with:
github-token: ${{ github.token }}
support-label: 'support'
issue-comment: >
:wave: @{issue-author}, we use the issue tracker exclusively
for bug reports and feature requests. However, this issue appears
to be a support request. Please use one of the support channels:
[forums](https://forums.sonarr.tv/), [subreddit](https://www.reddit.com/r/sonarr/),
[discord](https://discord.gg/Ex7FmFK), or [IRC](https://web.libera.chat/?channels=#sonarr)
for support/questions.
close-issue: true
issue-close-reason: 'not planned'
lock-issue: false
issue-lock-reason: 'off-topic'

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="70px" height="70px" viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<g>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-1.3318" y1="43.7371" x2="67.0419" y2="26.0967">
<stop offset="0.1237" style="stop-color:#7866FF"/>
<stop offset="0.5376" style="stop-color:#FE2EB6"/>
<stop offset="0.8548" style="stop-color:#FD0486"/>
</linearGradient>
<polygon style="fill:url(#SVGID_1_);" points="67.3,16 43.7,0 0,31.1 11.1,70 58.9,60.3 "/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="45.9148" y1="38.9098" x2="67.6577" y2="9.0989">
<stop offset="0.1237" style="stop-color:#FF0080"/>
<stop offset="0.2587" style="stop-color:#FE0385"/>
<stop offset="0.4109" style="stop-color:#FA0C92"/>
<stop offset="0.5713" style="stop-color:#F41BA9"/>
<stop offset="0.7363" style="stop-color:#EB2FC8"/>
<stop offset="0.8656" style="stop-color:#E343E6"/>
</linearGradient>
<polygon style="fill:url(#SVGID_2_);" points="67.3,16 43.7,0 38,15.7 38,47.8 70,47.8 "/>
</g>
<g>
<rect x="13.4" y="13.4" style="fill:#000000;" width="43.2" height="43.2"/>
<rect x="17.4" y="48.5" style="fill:#FFFFFF;" width="16.2" height="2.7"/>
<g>
<path style="fill:#FFFFFF;" d="M17.4,19.1h6.9c5.6,0,9.5,3.8,9.5,8.9V28c0,5-3.9,8.9-9.5,8.9h-6.9V19.1z M21.4,22.7v10.7h3
c3.2,0,5.4-2.2,5.4-5.3V28c0-3.2-2.2-5.4-5.4-5.4H21.4z"/>
<polygon style="fill:#FFFFFF;" points="40.3,22.7 34.9,22.7 34.9,19.1 49.6,19.1 49.6,22.7 44.2,22.7 44.2,37 40.3,37 "/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="120.1px" height="130.2px" viewBox="0 0 120.1 130.2" style="enable-background:new 0 0 120.1 130.2;" xml:space="preserve"
>
<g>
<linearGradient id="XMLID_2_" gradientUnits="userSpaceOnUse" x1="31.8412" y1="120.5578" x2="110.2402" y2="73.24">
<stop offset="0" style="stop-color:#FCEE39"/>
<stop offset="1" style="stop-color:#F37B3D"/>
</linearGradient>
<path id="XMLID_3041_" style="fill:url(#XMLID_2_);" d="M118.6,71.8c0.9-0.8,1.4-1.9,1.5-3.2c0.1-2.6-1.8-4.7-4.4-4.9
c-1.2-0.1-2.4,0.4-3.3,1.1l0,0l-83.8,45.9c-1.9,0.8-3.6,2.2-4.7,4.1c-2.9,4.8-1.3,11,3.6,13.9c3.4,2,7.5,1.8,10.7-0.2l0,0l0,0
c0.2-0.2,0.5-0.3,0.7-0.5l78-54.8C117.3,72.9,118.4,72.1,118.6,71.8L118.6,71.8L118.6,71.8z"/>
<linearGradient id="XMLID_3_" gradientUnits="userSpaceOnUse" x1="48.3607" y1="6.9083" x2="119.9179" y2="69.5546">
<stop offset="0" style="stop-color:#EF5A6B"/>
<stop offset="0.57" style="stop-color:#F26F4E"/>
<stop offset="1" style="stop-color:#F37B3D"/>
</linearGradient>
<path id="XMLID_3049_" style="fill:url(#XMLID_3_);" d="M118.8,65.1L118.8,65.1L55,2.5C53.6,1,51.6,0,49.3,0
c-4.3,0-7.7,3.5-7.7,7.7v0c0,2.1,0.8,3.9,2.1,5.3l0,0l0,0c0.4,0.4,0.8,0.7,1.2,1l67.4,57.7l0,0c0.8,0.7,1.8,1.2,3,1.3
c2.6,0.1,4.7-1.8,4.9-4.4C120.2,67.3,119.7,66,118.8,65.1z"/>
<linearGradient id="XMLID_4_" gradientUnits="userSpaceOnUse" x1="52.9467" y1="63.6407" x2="10.5379" y2="37.1562">
<stop offset="0" style="stop-color:#7C59A4"/>
<stop offset="0.3852" style="stop-color:#AF4C92"/>
<stop offset="0.7654" style="stop-color:#DC4183"/>
<stop offset="0.957" style="stop-color:#ED3D7D"/>
</linearGradient>
<path id="XMLID_3042_" style="fill:url(#XMLID_4_);" d="M57.1,59.5C57,59.5,17.7,28.5,16.9,28l0,0l0,0c-0.6-0.3-1.2-0.6-1.8-0.9
c-5.8-2.2-12.2,0.8-14.4,6.6c-1.9,5.1,0.2,10.7,4.6,13.4l0,0l0,0C6,47.5,6.6,47.8,7.3,48c0.4,0.2,45.4,18.8,45.4,18.8l0,0
c1.8,0.8,3.9,0.3,5.1-1.2C59.3,63.7,59,61,57.1,59.5z"/>
<linearGradient id="XMLID_5_" gradientUnits="userSpaceOnUse" x1="52.1736" y1="3.7019" x2="10.7706" y2="37.8971">
<stop offset="0" style="stop-color:#EF5A6B"/>
<stop offset="0.364" style="stop-color:#EE4E72"/>
<stop offset="1" style="stop-color:#ED3D7D"/>
</linearGradient>
<path id="XMLID_3057_" style="fill:url(#XMLID_5_);" d="M49.3,0c-1.7,0-3.3,0.6-4.6,1.5L4.9,28.3c-0.1,0.1-0.2,0.1-0.2,0.2l-0.1,0
l0,0c-1.7,1.2-3.1,3-3.9,5.1C-1.5,39.4,1.5,45.9,7.3,48c3.6,1.4,7.5,0.7,10.4-1.4l0,0l0,0c0.7-0.5,1.3-1,1.8-1.6l34.6-31.2l0,0
c1.8-1.4,3-3.6,3-6.1v0C57.1,3.5,53.6,0,49.3,0z"/>
<g id="XMLID_3008_">
<rect id="XMLID_3033_" x="34.6" y="37.4" style="fill:#000000;" width="51" height="51"/>
<rect id="XMLID_3032_" x="39" y="78.8" style="fill:#FFFFFF;" width="19.1" height="3.2"/>
<g id="XMLID_3009_">
<path id="XMLID_3030_" style="fill:#FFFFFF;" d="M38.8,50.8l1.5-1.4c0.4,0.5,0.8,0.8,1.3,0.8c0.6,0,0.9-0.4,0.9-1.2l0-5.3l2.3,0
l0,5.3c0,1-0.3,1.8-0.8,2.3c-0.5,0.5-1.3,0.8-2.3,0.8C40.2,52.2,39.4,51.6,38.8,50.8z"/>
<path id="XMLID_3028_" style="fill:#FFFFFF;" d="M45.3,43.8l6.7,0v1.9l-4.4,0V47l4,0l0,1.8l-4,0l0,1.3l4.5,0l0,2l-6.7,0
L45.3,43.8z"/>
<path id="XMLID_3026_" style="fill:#FFFFFF;" d="M55,45.8l-2.5,0l0-2l7.3,0l0,2l-2.5,0l0,6.3l-2.3,0L55,45.8z"/>
<path id="XMLID_3022_" style="fill:#FFFFFF;" d="M39,54l4.3,0c1,0,1.8,0.3,2.3,0.7c0.3,0.3,0.5,0.8,0.5,1.4v0
c0,1-0.5,1.5-1.3,1.9c1,0.3,1.6,0.9,1.6,2v0c0,1.4-1.2,2.3-3.1,2.3l-4.3,0L39,54z M43.8,56.6c0-0.5-0.4-0.7-1-0.7l-1.5,0l0,1.5
l1.4,0C43.4,57.3,43.8,57.1,43.8,56.6L43.8,56.6z M43,59l-1.8,0l0,1.5H43c0.7,0,1.1-0.3,1.1-0.8v0C44.1,59.2,43.7,59,43,59z"/>
<path id="XMLID_3019_" style="fill:#FFFFFF;" d="M46.8,54l3.9,0c1.3,0,2.1,0.3,2.7,0.9c0.5,0.5,0.7,1.1,0.7,1.9v0
c0,1.3-0.7,2.1-1.7,2.6l2,2.9l-2.6,0l-1.7-2.5h-1l0,2.5l-2.3,0L46.8,54z M50.6,58c0.8,0,1.2-0.4,1.2-1v0c0-0.7-0.5-1-1.2-1
l-1.5,0v2H50.6z"/>
<path id="XMLID_3016_" style="fill:#FFFFFF;" d="M56.8,54l2.2,0l3.5,8.4l-2.5,0l-0.6-1.5l-3.2,0l-0.6,1.5l-2.4,0L56.8,54z
M58.8,59l-0.9-2.3L57,59L58.8,59z"/>
<path id="XMLID_3014_" style="fill:#FFFFFF;" d="M62.8,54l2.3,0l0,8.3l-2.3,0L62.8,54z"/>
<path id="XMLID_3012_" style="fill:#FFFFFF;" d="M65.7,54l2.1,0l3.4,4.4l0-4.4l2.3,0l0,8.3l-2,0L68,57.8l0,4.6l-2.3,0L65.7,54z"
/>
<path id="XMLID_3010_" style="fill:#FFFFFF;" d="M73.7,61.1l1.3-1.5c0.8,0.7,1.7,1,2.7,1c0.6,0,1-0.2,1-0.6v0
c0-0.4-0.3-0.5-1.4-0.8c-1.8-0.4-3.1-0.9-3.1-2.6v0c0-1.5,1.2-2.7,3.2-2.7c1.4,0,2.5,0.4,3.4,1.1l-1.2,1.6
c-0.8-0.5-1.6-0.8-2.3-0.8c-0.6,0-0.8,0.2-0.8,0.5v0c0,0.4,0.3,0.5,1.4,0.8c1.9,0.4,3.1,1,3.1,2.6v0c0,1.7-1.3,2.7-3.4,2.7
C76.1,62.5,74.7,62,73.7,61.1z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="70px" height="70px" viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<g>
<g>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="22.9451" y1="75.7869" x2="74.7868" y2="20.6415">
<stop offset="1.612903e-002" style="stop-color:#B35BA3"/>
<stop offset="0.4044" style="stop-color:#C41E57"/>
<stop offset="0.4677" style="stop-color:#C41E57"/>
<stop offset="0.6505" style="stop-color:#EB8523"/>
<stop offset="0.9516" style="stop-color:#FEBD11"/>
</linearGradient>
<polygon style="fill:url(#SVGID_1_);" points="49.8,15.2 36,36.7 58.4,70 70,23.1 "/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="17.7187" y1="73.2922" x2="69.5556" y2="18.1519">
<stop offset="1.612903e-002" style="stop-color:#B35BA3"/>
<stop offset="0.4044" style="stop-color:#C41E57"/>
<stop offset="0.4677" style="stop-color:#C41E57"/>
<stop offset="0.7043" style="stop-color:#EB8523"/>
</linearGradient>
<polygon style="fill:url(#SVGID_2_);" points="51.1,15.7 49,0 18.8,33.6 27.6,42.3 20.8,70 58.4,70 "/>
</g>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="1.8281" y1="53.4275" x2="48.8245" y2="9.2255">
<stop offset="1.612903e-002" style="stop-color:#B35BA3"/>
<stop offset="0.6613" style="stop-color:#C41E57"/>
</linearGradient>
<polygon style="fill:url(#SVGID_3_);" points="49,0 11.6,0 0,47.1 55.6,47.1 "/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="49.8935" y1="-11.5569" x2="48.8588" y2="24.0352">
<stop offset="0.5" style="stop-color:#C41E57"/>
<stop offset="0.6668" style="stop-color:#D13F48"/>
<stop offset="0.7952" style="stop-color:#D94F39"/>
<stop offset="0.8656" style="stop-color:#DD5433"/>
</linearGradient>
<polygon style="fill:url(#SVGID_4_);" points="55.3,47.1 51.1,15.7 49,0 41.7,23 "/>
</g>
<g>
<rect x="13.4" y="13.5" transform="matrix(-1 2.577289e-003 -2.577289e-003 -1 70.0288 70.081)" style="fill:#000000;" width="43.2" height="43.2"/>
<rect x="17.6" y="48.6" transform="matrix(1 -2.577289e-003 2.577289e-003 1 -0.1287 6.634109e-002)" style="fill:#FFFFFF;" width="16.2" height="2.7"/>
<path style="fill:#FFFFFF;" d="M17.4,19.1l8.2,0c2.3,0,4,0.6,5.2,1.8c1,1,1.5,2.4,1.5,4.1l0,0.1c0,1.5-0.3,2.6-1.1,3.5
c-0.7,0.9-1.6,1.6-2.8,2l4.4,6.4l-4.6,0l-3.7-5.5l-3.3,0l0,5.5l-3.9,0L17.4,19.1z M25.3,27.8c1,0,1.7-0.2,2.2-0.7
c0.5-0.5,0.8-1.1,0.8-1.8l0-0.1c0-0.9-0.3-1.5-0.8-1.9c-0.5-0.4-1.3-0.6-2.3-0.6l-3.9,0l0,5.1L25.3,27.8z"/>
<path style="fill:#FFFFFF;" d="M36,33.2l-1.9,0l0-3.3l2.5,0l0.6-3.8l-2.3,0l0-3.3l2.8,0l0.6-3.7l3.4,0l-0.6,3.7l3.7,0l0.6-3.7
l3.4,0l-0.6,3.7l1.9,0l0,3.3l-2.5,0L47,29.9l2.3,0l0,3.3l-2.8,0L45.8,37l-3.4,0l0.7-3.8l-3.7,0L38.7,37l-3.4,0L36,33.2z
M43.7,29.9l0.6-3.8l-3.7,0L40,29.9L43.7,29.9z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<g>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="1.7738" y1="31.2729" x2="40.1662" y2="31.2729">
<stop offset="0" style="stop-color:#905CFB"/>
<stop offset="6.772543e-002" style="stop-color:#776CF9"/>
<stop offset="0.1729" style="stop-color:#5681F7"/>
<stop offset="0.2865" style="stop-color:#3B92F5"/>
<stop offset="0.4097" style="stop-color:#269FF4"/>
<stop offset="0.5474" style="stop-color:#17A9F3"/>
<stop offset="0.7111" style="stop-color:#0FAEF2"/>
<stop offset="0.9677" style="stop-color:#0CB0F2"/>
</linearGradient>
<path style="fill:url(#SVGID_1_);" d="M39.7,47.9l-6.1-34c-0.4-2.4-1.2-4.8-2.7-7.1c-2-3.2-5.2-5.4-8.8-6.3
C7.9-2.9-2.6,11.3,3.6,23.9c0,0,0,0,0,0l14.8,31.7c0.4,1,1,2,1.7,2.9c1.2,1.6,2.8,2.8,4.7,3.4C34.4,64.9,42.1,56.4,39.7,47.9z"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="5.3113" y1="9.6691" x2="69.2278" y2="43.8664">
<stop offset="0" style="stop-color:#905CFB"/>
<stop offset="6.772543e-002" style="stop-color:#776CF9"/>
<stop offset="0.1729" style="stop-color:#5681F7"/>
<stop offset="0.2865" style="stop-color:#3B92F5"/>
<stop offset="0.4097" style="stop-color:#269FF4"/>
<stop offset="0.5474" style="stop-color:#17A9F3"/>
<stop offset="0.7111" style="stop-color:#0FAEF2"/>
<stop offset="0.9677" style="stop-color:#0CB0F2"/>
</linearGradient>
<path style="fill:url(#SVGID_2_);" d="M67.4,26.5c-1.4-2.2-3.4-3.9-5.7-4.9L25.5,1.7l0,0c-1-0.5-2.1-1-3.3-1.3
C6.7-3.2-4.4,13.8,5.5,27c1.5,2,3.6,3.6,6,4.5L48,47.9c0.8,0.5,1.6,0.8,2.5,1.1C64.5,53.4,75.1,38.6,67.4,26.5z"/>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="-19.2836" y1="70.8198" x2="55.9833" y2="33.1863">
<stop offset="0" style="stop-color:#3BEA62"/>
<stop offset="0.117" style="stop-color:#31DE80"/>
<stop offset="0.3025" style="stop-color:#24CEA8"/>
<stop offset="0.4844" style="stop-color:#1AC1C9"/>
<stop offset="0.6592" style="stop-color:#12B7DF"/>
<stop offset="0.8238" style="stop-color:#0EB2ED"/>
<stop offset="0.9677" style="stop-color:#0CB0F2"/>
</linearGradient>
<path style="fill:url(#SVGID_3_);" d="M67.4,26.5c-1.8-2.8-4.6-4.8-7.9-5.6c-3.5-0.8-6.8-0.5-9.6,0.7L11.4,36.1
c0,0-0.2,0.1-0.6,0.4C0.9,40.4-4,53.3,4,64c1.8,2.4,4.3,4.2,7.1,5c5.3,1.6,10.1,1,14-1.1c0,0,0.1,0,0.1,0l37.6-20.1
c0,0,0,0,0.1-0.1C69.5,43.9,72.6,34.6,67.4,26.5z"/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="38.9439" y1="5.8503" x2="5.4232" y2="77.5093">
<stop offset="0" style="stop-color:#3BEA62"/>
<stop offset="9.397750e-002" style="stop-color:#2FDB87"/>
<stop offset="0.196" style="stop-color:#24CEA8"/>
<stop offset="0.3063" style="stop-color:#1BC3C3"/>
<stop offset="0.4259" style="stop-color:#14BAD8"/>
<stop offset="0.5596" style="stop-color:#10B5E7"/>
<stop offset="0.7185" style="stop-color:#0DB1EF"/>
<stop offset="0.9677" style="stop-color:#0CB0F2"/>
</linearGradient>
<path style="fill:url(#SVGID_4_);" d="M50.3,12.8c1.2-2.7,1.1-6-0.9-9c-1.1-1.8-2.9-3-4.9-3.5c-4.5-1.1-8.3,1-10.1,4.2L3.5,42
c0,0,0,0,0,0.1C-0.9,47.9-1.6,56.5,4,64c1.8,2.4,4.3,4.2,7.1,5c10.5,3.3,19.3-2.5,22.1-10.8L50.3,12.8z"/>
</g>
<g>
<rect x="13.4" y="13.4" style="fill:#000000;" width="43.2" height="43.2"/>
<rect x="17.5" y="48.5" style="fill:#FFFFFF;" width="16.2" height="2.7"/>
<polygon style="fill:#FFFFFF;" points="22.9,22.7 17.5,22.7 17.5,19.1 32.3,19.1 32.3,22.7 26.8,22.7 26.8,37 22.9,37 "/>
<path style="fill:#FFFFFF;" d="M32.5,28.1L32.5,28.1c0-5.1,3.8-9.3,9.3-9.3c3.4,0,5.4,1.1,7.1,2.8l-2.5,2.9c-1.4-1.3-2.8-2-4.6-2
c-3,0-5.2,2.5-5.2,5.6V28c0,3.1,2.1,5.6,5.2,5.6c2,0,3.3-0.8,4.7-2.1l2.5,2.5c-1.8,2-3.9,3.2-7.3,3.2
C36.4,37.3,32.5,33.2,32.5,28.1"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,6 +1,6 @@
# <img width="24px" src="./Logo/256.png" alt="Sonarr"></img> Sonarr
# <img width="24px" src="./Logo/256.png" alt="Sonarr"></img> Sonarr
[![Translated](https://translate.servarr.com/widget/servarr/sonarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/)
[![Translated](https://translate.servarr.com/widgets/servarr/-/sonarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/)
[![Backers on Open Collective](https://opencollective.com/Sonarr/backers/badge.svg)](#backers)
[![Sponsors on Open Collective](https://opencollective.com/Sonarr/sponsors/badge.svg)](#sponsors)
[![Mega Sponsors on Open Collective](https://opencollective.com/Sonarr/megasponsors/badge.svg)](#mega-sponsors)
@@ -33,7 +33,7 @@ Note: GitHub Issues are for Bugs and Feature Requests Only
- Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc.
- Automatically detects new episodes
- Can scan your existing library and download any missing episodes
- Can watch for better quality of the episodes you already have and do an automatic upgrade. _eg. from DVD to Blu-Ray_
- Can watch for better quality of the episodes you already have and do an automatic upgrade. *eg. from DVD to Blu-Ray*
- Automatic failed download handling will try another release if one fails
- Manual search so you can pick any release or to see why a release was not downloaded automatically
- Fully configurable episode renaming
@@ -52,7 +52,7 @@ This project exists thanks to all the people who contribute. [Contribute](CONTRI
### Supporters
This project would not be possible without the support of our users and software providers.
This project would not be possible without the support of our users and software providers.
[**Become a sponsor or backer**](https://opencollective.com/sonarr) to help us out!
#### Mega Sponsors
@@ -69,17 +69,13 @@ This project would not be possible without the support of our users and software
#### JetBrains
Thank you to [<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.png" alt="JetBrains" width="96">](http://www.jetbrains.com/) for providing us with free licenses to their great tools
Thank you to [<img src="/Logo/Jetbrains/jetbrains.svg" alt="JetBrains" width="32"> JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/TeamCity.png" alt="TeamCity" width="64">](http://www.jetbrains.com/teamcity/)
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/ReSharper.png" alt="ReSharper" width="64">](http://www.jetbrains.com/resharper/)
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/dotTrace.png" alt="dotTrace" width="64">](http://www.jetbrains.com/dottrace/)
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/Rider.png" alt="Rider" width="64">](http://www.jetbrains.com/rider/)
* [<img src="/Logo/Jetbrains/teamcity.svg" alt="TeamCity" width="32"> TeamCity](http://www.jetbrains.com/teamcity/)
* [<img src="/Logo/Jetbrains/resharper.svg" alt="ReSharper" width="32"> ReSharper](http://www.jetbrains.com/resharper/)
* [<img src="/Logo/Jetbrains/dottrace.svg" alt="dotTrace" width="32"> dotTrace](http://www.jetbrains.com/dottrace/)
### Licenses
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
- Copyright 2010-2024
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
- Copyright 2010-2023

View File

@@ -210,6 +210,7 @@ module.exports = {
'no-undef-init': 'off',
'no-undefined': 'off',
'no-unused-vars': ['error', { args: 'none', ignoreRestSiblings: true }],
'no-use-before-define': 'error',
// Node.js and CommonJS
@@ -363,11 +364,7 @@ module.exports = {
{
args: 'after-used',
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true
}
],
'@typescript-eslint/explicit-function-return-type': 'off',

View File

@@ -26,7 +26,6 @@ module.exports = (env) => {
const config = {
mode: isProduction ? 'production' : 'development',
devtool: isProduction ? 'source-map' : 'eval-source-map',
target: 'web',
stats: {
children: false
@@ -52,7 +51,8 @@ module.exports = (env) => {
'node_modules'
],
alias: {
jquery: 'jquery/dist/jquery.min'
jquery: 'jquery/dist/jquery.min',
'react-middle-truncate': 'react-middle-truncate/lib/react-middle-truncate'
},
fallback: {
buffer: false,
@@ -187,7 +187,7 @@ module.exports = (env) => {
loose: true,
debug: false,
useBuiltIns: 'entry',
corejs: '3.39'
corejs: 3
}
]
]

View File

@@ -41,7 +41,6 @@ function HistoryDetails(props: HistoryDetailsProps) {
indexer,
releaseGroup,
seriesMatchType,
releaseSource,
customFormatScore,
nzbInfoUrl,
downloadClient,
@@ -54,31 +53,6 @@ function HistoryDetails(props: HistoryDetailsProps) {
const downloadClientNameInfo = downloadClientName ?? downloadClient;
let releaseSourceMessage = '';
switch (releaseSource) {
case 'Unknown':
releaseSourceMessage = translate('Unknown');
break;
case 'Rss':
releaseSourceMessage = translate('Rss');
break;
case 'Search':
releaseSourceMessage = translate('Search');
break;
case 'UserInvokedSearch':
releaseSourceMessage = translate('UserInvokedSearch');
break;
case 'InteractiveSearch':
releaseSourceMessage = translate('InteractiveSearch');
break;
case 'ReleasePush':
releaseSourceMessage = translate('ReleasePush');
break;
default:
releaseSourceMessage = '';
}
return (
<DescriptionList>
<DescriptionListItem
@@ -114,14 +88,6 @@ function HistoryDetails(props: HistoryDetailsProps) {
/>
) : null}
{releaseSource ? (
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('ReleaseSource')}
data={releaseSourceMessage}
/>
) : null}
{nzbInfoUrl ? (
<span>
<DescriptionListItemTitle>

View File

@@ -195,14 +195,9 @@ function HistoryRow(props: HistoryRowProps) {
}
if (name === 'downloadClient') {
const downloadClientName =
'downloadClientName' in data ? data.downloadClientName : null;
const downloadClient =
'downloadClient' in data ? data.downloadClient : null;
return (
<TableRowCell key={name} className={styles.downloadClient}>
{downloadClientName ?? downloadClient ?? ''}
{'downloadClient' in data ? data.downloadClient : ''}
</TableRowCell>
);
}

View File

@@ -1,6 +1,5 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
@@ -130,8 +129,7 @@ class AddNewSeries extends Component {
<div className={styles.helpText}>
{translate('AddNewSeriesError')}
</div>
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
<div>{getErrorMessage(error)}</div>
</div> : null
}

View File

@@ -70,15 +70,10 @@
}
.originalLanguageName,
.network,
.genres {
.network {
margin-left: 8px;
}
.genres {
pointer-events: all;
}
.tvdbLink {
composes: link from '~Components/Link/Link.css';

View File

@@ -3,7 +3,6 @@
interface CssExports {
'alreadyExistsIcon': string;
'content': string;
'genres': string;
'icons': string;
'network': string;
'originalLanguageName': string;

View File

@@ -6,7 +6,6 @@ import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import MetadataAttribution from 'Components/MetadataAttribution';
import { icons, kinds, sizes } from 'Helpers/Props';
import SeriesGenres from 'Series/SeriesGenres';
import SeriesPoster from 'Series/SeriesPoster';
import translate from 'Utilities/String/translate';
import AddNewSeriesModal from './AddNewSeriesModal';
@@ -57,7 +56,6 @@ class AddNewSeriesSearchResult extends Component {
year,
network,
originalLanguage,
genres,
status,
overview,
statistics,
@@ -183,18 +181,6 @@ class AddNewSeriesSearchResult extends Component {
null
}
{
genres.length > 0 ?
<Label size={sizes.LARGE}>
<Icon
name={icons.GENRE}
size={13}
/>
<SeriesGenres className={styles.genres} genres={genres} />
</Label> :
null
}
{
seasonCount ?
<Label size={sizes.LARGE}>
@@ -257,7 +243,6 @@ AddNewSeriesSearchResult.propTypes = {
year: PropTypes.number.isRequired,
network: PropTypes.string,
originalLanguage: PropTypes.object,
genres: PropTypes.arrayOf(PropTypes.string),
status: PropTypes.string.isRequired,
overview: PropTypes.string,
statistics: PropTypes.object.isRequired,
@@ -269,8 +254,4 @@ AddNewSeriesSearchResult.propTypes = {
isSmallScreen: PropTypes.bool.isRequired
};
AddNewSeriesSearchResult.defaultProps = {
genres: []
};
export default AddNewSeriesSearchResult;

View File

@@ -1,10 +1,18 @@
.inputContainer {
margin-right: 20px;
min-width: 150px;
div {
margin-top: 10px;
&:first-child {
margin-top: 0;
}
}
}
.label {
margin-bottom: 10px;
margin-bottom: 3px;
font-weight: bold;
}

View File

@@ -1,4 +1,3 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
import React from 'react';
import DocumentTitle from 'react-document-title';
@@ -13,21 +12,17 @@ interface AppProps {
history: ConnectedRouterProps['history'];
}
const queryClient = new QueryClient();
function App({ store, history }: AppProps) {
return (
<DocumentTitle title={window.Sonarr.instanceName}>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<ConnectedRouter history={history}>
<ApplyTheme />
<PageConnector>
<AppRoutes />
</PageConnector>
</ConnectedRouter>
</Provider>
</QueryClientProvider>
<Provider store={store}>
<ConnectedRouter history={history}>
<ApplyTheme />
<PageConnector>
<AppRoutes />
</PageConnector>
</ConnectedRouter>
</Provider>
</DocumentTitle>
);
}

View File

@@ -5,7 +5,7 @@ import History from 'Activity/History/History';
import Queue from 'Activity/Queue/Queue';
import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector';
import ImportSeries from 'AddSeries/ImportSeries/ImportSeries';
import CalendarPage from 'Calendar/CalendarPage';
import CalendarPageConnector from 'Calendar/CalendarPageConnector';
import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
@@ -72,7 +72,7 @@ function AppRoutes() {
Calendar
*/}
<Route path="/calendar" component={CalendarPage} />
<Route path="/calendar" component={CalendarPageConnector} />
{/*
Activity

View File

@@ -1,16 +1,11 @@
import Column from 'Components/Table/Column';
import { SortDirection } from 'Helpers/Props/sortDirections';
import { ValidationFailure } from 'typings/pending';
import { FilterBuilderProp, PropertyFilter } from './AppState';
export interface Error {
status?: number;
responseJSON:
| {
message: string | undefined;
}
| ValidationFailure[]
| undefined;
responseJSON: {
message: string;
};
}
export interface AppSectionDeleteState {
@@ -63,16 +58,6 @@ export interface AppSectionItemState<T> {
item: T;
}
export interface AppSectionProviderState<T>
extends AppSectionDeleteState,
AppSectionSaveState {
isFetching: boolean;
isPopulated: boolean;
error: Error;
items: T[];
pendingChanges: Partial<T>;
}
interface AppSectionState<T> {
isFetching: boolean;
isPopulated: boolean;

View File

@@ -1,15 +1,12 @@
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
import CaptchaAppState from './CaptchaAppState';
import CommandAppState from './CommandAppState';
import EpisodeFilesAppState from './EpisodeFilesAppState';
import EpisodesAppState from './EpisodesAppState';
import HistoryAppState from './HistoryAppState';
import InteractiveImportAppState from './InteractiveImportAppState';
import OAuthAppState from './OAuthAppState';
import ParseAppState from './ParseAppState';
import PathsAppState from './PathsAppState';
import ProviderOptionsAppState from './ProviderOptionsAppState';
import QueueAppState from './QueueAppState';
import ReleasesAppState from './ReleasesAppState';
import RootFolderAppState from './RootFolderAppState';
@@ -54,12 +51,10 @@ export interface CustomFilter {
export interface AppSectionState {
isConnected: boolean;
isReconnecting: boolean;
isSidebarVisible: boolean;
version: string;
prevVersion?: string;
dimensions: {
isSmallScreen: boolean;
isLargeScreen: boolean;
width: number;
height: number;
};
@@ -69,18 +64,14 @@ interface AppState {
app: AppSectionState;
blocklist: BlocklistAppState;
calendar: CalendarAppState;
captcha: CaptchaAppState;
commands: CommandAppState;
episodeFiles: EpisodeFilesAppState;
episodeHistory: HistoryAppState;
episodes: EpisodesAppState;
episodesSelection: EpisodesAppState;
history: HistoryAppState;
interactiveImport: InteractiveImportAppState;
oAuth: OAuthAppState;
parse: ParseAppState;
paths: PathsAppState;
providerOptions: ProviderOptionsAppState;
queue: QueueAppState;
releases: ReleasesAppState;
rootFolders: RootFolderAppState;

View File

@@ -1,29 +1,10 @@
import moment from 'moment';
import AppSectionState, {
AppSectionFilterState,
} from 'App/State/AppSectionState';
import { CalendarView } from 'Calendar/calendarViews';
import { CalendarItem } from 'typings/Calendar';
interface CalendarOptions {
showEpisodeInformation: boolean;
showFinaleIcon: boolean;
showSpecialIcon: boolean;
showCutoffUnmetIcon: boolean;
collapseMultipleEpisodes: boolean;
fullColorEvents: boolean;
}
import Episode from 'Episode/Episode';
interface CalendarAppState
extends AppSectionState<CalendarItem>,
AppSectionFilterState<CalendarItem> {
searchMissingCommandId: number | null;
start: moment.Moment;
end: moment.Moment;
dates: string[];
time: string;
view: CalendarView;
options: CalendarOptions;
}
extends AppSectionState<Episode>,
AppSectionFilterState<Episode> {}
export default CalendarAppState;

View File

@@ -1,11 +0,0 @@
interface CaptchaAppState {
refreshing: false;
token: string;
siteKey: unknown;
secretToken: unknown;
ray: unknown;
stoken: unknown;
responseUrl: unknown;
}
export default CaptchaAppState;

View File

@@ -1,20 +1,11 @@
import AppSectionState from 'App/State/AppSectionState';
import RecentFolder from 'InteractiveImport/Folder/RecentFolder';
import ImportMode from 'InteractiveImport/ImportMode';
import InteractiveImport from 'InteractiveImport/InteractiveImport';
interface FavoriteFolder {
folder: string;
}
interface RecentFolder {
folder: string;
lastUsed: string;
}
interface InteractiveImportAppState extends AppSectionState<InteractiveImport> {
originalItems: InteractiveImport[];
importMode: ImportMode;
favoriteFolders: FavoriteFolder[];
recentFolders: RecentFolder[];
}

View File

@@ -1,6 +0,0 @@
import { AppSectionProviderState } from 'App/State/AppSectionState';
import Metadata from 'typings/Metadata';
type MetadataAppState = AppSectionProviderState<Metadata>;
export default MetadataAppState;

View File

@@ -1,9 +0,0 @@
import { Error } from './AppSectionState';
interface OAuthAppState {
authorizing: boolean;
result: Record<string, unknown> | null;
error: Error;
}
export default OAuthAppState;

View File

@@ -1,22 +0,0 @@
import AppSectionState from 'App/State/AppSectionState';
import Field, { FieldSelectOption } from 'typings/Field';
export interface ProviderOptions {
fields?: Field[];
}
interface ProviderOptionsDevice {
id: string;
name: string;
}
interface ProviderOptionsAppState {
devices: AppSectionState<ProviderOptionsDevice>;
servers: AppSectionState<FieldSelectOption<unknown>>;
newznabCategories: AppSectionState<FieldSelectOption<unknown>>;
getProfiles: AppSectionState<FieldSelectOption<unknown>>;
getTags: AppSectionState<FieldSelectOption<unknown>>;
getRootFolders: AppSectionState<FieldSelectOption<unknown>>;
}
export default ProviderOptionsAppState;

View File

@@ -59,8 +59,6 @@ interface SeriesAppState
deleteOptions: {
addImportListExclusion: boolean;
};
pendingChanges: Partial<Series>;
}
export default SeriesAppState;

View File

@@ -16,11 +16,7 @@ import IndexerFlag from 'typings/IndexerFlag';
import Notification from 'typings/Notification';
import QualityProfile from 'typings/QualityProfile';
import General from 'typings/Settings/General';
import NamingConfig from 'typings/Settings/NamingConfig';
import NamingExample from 'typings/Settings/NamingExample';
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
import UiSettings from 'typings/Settings/UiSettings';
import MetadataAppState from './MetadataAppState';
export interface DownloadClientAppState
extends AppSectionState<DownloadClient>,
@@ -33,12 +29,6 @@ export interface GeneralAppState
extends AppSectionItemState<General>,
AppSectionSaveState {}
export interface NamingAppState
extends AppSectionItemState<NamingConfig>,
AppSectionSaveState {}
export type NamingExamplesAppState = AppSectionItemState<NamingExample>;
export interface ImportListAppState
extends AppSectionState<ImportList>,
AppSectionDeleteState,
@@ -59,12 +49,6 @@ export interface QualityProfilesAppState
extends AppSectionState<QualityProfile>,
AppSectionItemSchemaState<QualityProfile> {}
export interface ReleaseProfilesAppState
extends AppSectionState<ReleaseProfile>,
AppSectionSaveState {
pendingChanges: Partial<ReleaseProfile>;
}
export interface CustomFormatAppState
extends AppSectionState<CustomFormat>,
AppSectionDeleteState,
@@ -97,12 +81,8 @@ interface SettingsAppState {
indexerFlags: IndexerFlagSettingsAppState;
indexers: IndexerAppState;
languages: LanguageSettingsAppState;
metadata: MetadataAppState;
naming: NamingAppState;
namingExamples: NamingExamplesAppState;
notifications: NotificationAppState;
qualityProfiles: QualityProfilesAppState;
releaseProfiles: ReleaseProfilesAppState;
ui: UiSettingsAppState;
}

View File

@@ -1,9 +1,9 @@
import AppSectionState from 'App/State/AppSectionState';
import Episode from 'Episode/Episode';
type WantedCutoffUnmetAppState = AppSectionState<Episode>;
interface WantedCutoffUnmetAppState extends AppSectionState<Episode> {}
type WantedMissingAppState = AppSectionState<Episode>;
interface WantedMissingAppState extends AppSectionState<Episode> {}
interface WantedAppState {
cutoffUnmet: WantedCutoffUnmetAppState;

View File

@@ -0,0 +1,38 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React from 'react';
import AgendaEventConnector from './AgendaEventConnector';
import styles from './Agenda.css';
function Agenda(props) {
const {
items
} = props;
return (
<div className={styles.agenda}>
{
items.map((item, index) => {
const momentDate = moment(item.airDateUtc);
const showDate = index === 0 ||
!moment(items[index - 1].airDateUtc).isSame(momentDate, 'day');
return (
<AgendaEventConnector
key={item.id}
episodeId={item.id}
showDate={showDate}
{...item}
/>
);
})
}
</div>
);
}
Agenda.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default Agenda;

View File

@@ -1,25 +0,0 @@
import moment from 'moment';
import React from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import AgendaEvent from './AgendaEvent';
import styles from './Agenda.css';
function Agenda() {
const { items } = useSelector((state: AppState) => state.calendar);
return (
<div className={styles.agenda}>
{items.map((item, index) => {
const momentDate = moment(item.airDateUtc);
const showDate =
index === 0 ||
!moment(items[index - 1].airDateUtc).isSame(momentDate, 'day');
return <AgendaEvent key={item.id} showDate={showDate} {...item} />;
})}
</div>
);
}
export default Agenda;

View File

@@ -0,0 +1,14 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import Agenda from './Agenda';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar,
(calendar) => {
return calendar;
}
);
}
export default connect(createMapStateToProps)(Agenda);

View File

@@ -0,0 +1,254 @@
import classNames from 'classnames';
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
import episodeEntities from 'Episode/episodeEntities';
import getFinaleTypeName from 'Episode/getFinaleTypeName';
import { icons, kinds } from 'Helpers/Props';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
import styles from './AgendaEvent.css';
class AgendaEvent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false
};
}
//
// Listeners
onPress = () => {
this.setState({ isDetailsModalOpen: true });
};
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false });
};
//
// Render
render() {
const {
id,
series,
episodeFile,
title,
seasonNumber,
episodeNumber,
absoluteEpisodeNumber,
airDateUtc,
monitored,
unverifiedSceneNumbering,
finaleType,
hasFile,
grabbed,
queueItem,
showDate,
showEpisodeInformation,
showFinaleIcon,
showSpecialIcon,
showCutoffUnmetIcon,
timeFormat,
longDateFormat,
colorImpairedMode
} = this.props;
const startTime = moment(airDateUtc);
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
const downloading = !!(queueItem || grabbed);
const isMonitored = series.monitored && monitored;
const statusStyle = getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored);
const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
return (
<div className={styles.event}>
<Link
className={styles.underlay}
onPress={this.onPress}
/>
<div className={styles.overlay}>
<div className={styles.date}>
{
showDate &&
startTime.format(longDateFormat)
}
</div>
<div
className={classNames(
styles.eventWrapper,
styles[statusStyle],
colorImpairedMode && 'colorImpaired'
)}
>
<div className={styles.time}>
{formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
</div>
<div className={styles.seriesTitle}>
{series.title}
</div>
{
showEpisodeInformation &&
<div className={styles.seasonEpisodeNumber}>
{seasonNumber}x{padNumber(episodeNumber, 2)}
{
series.seriesType === 'anime' && absoluteEpisodeNumber &&
<span className={styles.absoluteEpisodeNumber}>({absoluteEpisodeNumber})</span>
}
<div className={styles.episodeSeparator}> - </div>
</div>
}
<div className={styles.episodeTitle}>
{
showEpisodeInformation &&
title
}
</div>
{
missingAbsoluteNumber &&
<Icon
className={styles.statusIcon}
name={icons.WARNING}
title={translate('EpisodeMissingAbsoluteNumber')}
/>
}
{
unverifiedSceneNumbering && !missingAbsoluteNumber ?
<Icon
className={styles.statusIcon}
name={icons.WARNING}
title={translate('SceneNumberNotVerified')}
/> :
null
}
{
!!queueItem &&
<span className={styles.statusIcon}>
<CalendarEventQueueDetails
seriesType={series.seriesType}
seasonNumber={seasonNumber}
absoluteEpisodeNumber={absoluteEpisodeNumber}
{...queueItem}
/>
</span>
}
{
!queueItem && grabbed &&
<Icon
className={styles.statusIcon}
name={icons.DOWNLOADING}
title={translate('EpisodeIsDownloading')}
/>
}
{
showCutoffUnmetIcon &&
!!episodeFile &&
episodeFile.qualityCutoffNotMet &&
<Icon
className={styles.statusIcon}
name={icons.EPISODE_FILE}
kind={kinds.WARNING}
title={translate('QualityCutoffNotMet')}
/>
}
{
episodeNumber === 1 && seasonNumber > 0 &&
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.INFO}
title={seasonNumber === 1 ? translate('SeriesPremiere') : translate('SeasonPremiere')}
/>
}
{
showFinaleIcon &&
finaleType ?
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.WARNING}
title={getFinaleTypeName(finaleType)}
/> :
null
}
{
showSpecialIcon &&
(episodeNumber === 0 || seasonNumber === 0) &&
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.PINK}
title={translate('Special')}
/>
}
</div>
</div>
<EpisodeDetailsModal
isOpen={this.state.isDetailsModalOpen}
episodeId={id}
episodeEntity={episodeEntities.CALENDAR}
seriesId={series.id}
episodeTitle={title}
showOpenSeriesButton={true}
onModalClose={this.onDetailsModalClose}
/>
</div>
);
}
}
AgendaEvent.propTypes = {
id: PropTypes.number.isRequired,
series: PropTypes.object.isRequired,
episodeFile: PropTypes.object,
title: PropTypes.string.isRequired,
seasonNumber: PropTypes.number.isRequired,
episodeNumber: PropTypes.number.isRequired,
absoluteEpisodeNumber: PropTypes.number,
airDateUtc: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
unverifiedSceneNumbering: PropTypes.bool,
finaleType: PropTypes.string,
hasFile: PropTypes.bool.isRequired,
grabbed: PropTypes.bool,
queueItem: PropTypes.object,
showDate: PropTypes.bool.isRequired,
showEpisodeInformation: PropTypes.bool.isRequired,
showFinaleIcon: PropTypes.bool.isRequired,
showSpecialIcon: PropTypes.bool.isRequired,
showCutoffUnmetIcon: PropTypes.bool.isRequired,
timeFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
colorImpairedMode: PropTypes.bool.isRequired
};
export default AgendaEvent;

View File

@@ -1,227 +0,0 @@
import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
import episodeEntities from 'Episode/episodeEntities';
import getFinaleTypeName from 'Episode/getFinaleTypeName';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
import styles from './AgendaEvent.css';
interface AgendaEventProps {
id: number;
seriesId: number;
episodeFileId: number;
title: string;
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber?: number;
airDateUtc: string;
monitored: boolean;
unverifiedSceneNumbering?: boolean;
finaleType?: string;
hasFile: boolean;
grabbed?: boolean;
showDate: boolean;
}
function AgendaEvent(props: AgendaEventProps) {
const {
id,
seriesId,
episodeFileId,
title,
seasonNumber,
episodeNumber,
absoluteEpisodeNumber,
airDateUtc,
monitored,
unverifiedSceneNumbering,
finaleType,
hasFile,
grabbed,
showDate,
} = props;
const series = useSeries(seriesId)!;
const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useSelector(createQueueItemSelectorForHook(id));
const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector()
);
const {
showEpisodeInformation,
showFinaleIcon,
showSpecialIcon,
showCutoffUnmetIcon,
} = useSelector((state: AppState) => state.calendar.options);
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const startTime = moment(airDateUtc);
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
const downloading = !!(queueItem || grabbed);
const isMonitored = series.monitored && monitored;
const statusStyle = getStatusStyle(
hasFile,
downloading,
startTime,
endTime,
isMonitored
);
const missingAbsoluteNumber =
series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
const handlePress = useCallback(() => {
setIsDetailsModalOpen(true);
}, []);
const handleDetailsModalClose = useCallback(() => {
setIsDetailsModalOpen(false);
}, []);
return (
<div className={styles.event}>
<Link className={styles.underlay} onPress={handlePress} />
<div className={styles.overlay}>
<div className={styles.date}>
{showDate && startTime.format(longDateFormat)}
</div>
<div
className={classNames(
styles.eventWrapper,
styles[statusStyle],
enableColorImpairedMode && 'colorImpaired'
)}
>
<div className={styles.time}>
{formatTime(airDateUtc, timeFormat)} -{' '}
{formatTime(endTime.toISOString(), timeFormat, {
includeMinuteZero: true,
})}
</div>
<div className={styles.seriesTitle}>{series.title}</div>
{showEpisodeInformation ? (
<div className={styles.seasonEpisodeNumber}>
{seasonNumber}x{padNumber(episodeNumber, 2)}
{series.seriesType === 'anime' && absoluteEpisodeNumber && (
<span className={styles.absoluteEpisodeNumber}>
({absoluteEpisodeNumber})
</span>
)}
<div className={styles.episodeSeparator}> - </div>
</div>
) : null}
<div className={styles.episodeTitle}>
{showEpisodeInformation ? title : null}
</div>
{missingAbsoluteNumber ? (
<Icon
className={styles.statusIcon}
name={icons.WARNING}
title={translate('EpisodeMissingAbsoluteNumber')}
/>
) : null}
{unverifiedSceneNumbering && !missingAbsoluteNumber ? (
<Icon
className={styles.statusIcon}
name={icons.WARNING}
title={translate('SceneNumberNotVerified')}
/>
) : null}
{queueItem ? (
<span className={styles.statusIcon}>
<CalendarEventQueueDetails
seasonNumber={seasonNumber}
{...queueItem}
/>
</span>
) : null}
{!queueItem && grabbed ? (
<Icon
className={styles.statusIcon}
name={icons.DOWNLOADING}
title={translate('EpisodeIsDownloading')}
/>
) : null}
{showCutoffUnmetIcon &&
episodeFile &&
episodeFile.qualityCutoffNotMet ? (
<Icon
className={styles.statusIcon}
name={icons.EPISODE_FILE}
kind={kinds.WARNING}
title={translate('QualityCutoffNotMet')}
/>
) : null}
{episodeNumber === 1 && seasonNumber > 0 && (
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.INFO}
title={
seasonNumber === 1
? translate('SeriesPremiere')
: translate('SeasonPremiere')
}
/>
)}
{showFinaleIcon && finaleType ? (
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.WARNING}
title={getFinaleTypeName(finaleType)}
/>
) : null}
{showSpecialIcon && (episodeNumber === 0 || seasonNumber === 0) ? (
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.PINK}
title={translate('Special')}
/>
) : null}
</div>
</div>
<EpisodeDetailsModal
isOpen={isDetailsModalOpen}
episodeId={id}
episodeEntity={episodeEntities.CALENDAR}
seriesId={series.id}
episodeTitle={title}
showOpenSeriesButton={true}
onModalClose={handleDetailsModalClose}
/>
</div>
);
}
export default AgendaEvent;

View File

@@ -0,0 +1,30 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import AgendaEvent from './AgendaEvent';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar.options,
createSeriesSelector(),
createEpisodeFileSelector(),
createQueueItemSelector(),
createUISettingsSelector(),
(calendarOptions, series, episodeFile, queueItem, uiSettings) => {
return {
series,
episodeFile,
queueItem,
...calendarOptions,
timeFormat: uiSettings.timeFormat,
longDateFormat: uiSettings.longDateFormat,
colorImpairedMode: uiSettings.enableColorImpairedMode
};
}
);
}
export default connect(createMapStateToProps)(AgendaEvent);

View File

@@ -0,0 +1,67 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import AgendaConnector from './Agenda/AgendaConnector';
import * as calendarViews from './calendarViews';
import CalendarDaysConnector from './Day/CalendarDaysConnector';
import DaysOfWeekConnector from './Day/DaysOfWeekConnector';
import CalendarHeaderConnector from './Header/CalendarHeaderConnector';
import styles from './Calendar.css';
class Calendar extends Component {
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
view
} = this.props;
return (
<div className={styles.calendar}>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
}
{
!error && isPopulated && view === calendarViews.AGENDA &&
<div className={styles.calendarContent}>
<CalendarHeaderConnector />
<AgendaConnector />
</div>
}
{
!error && isPopulated && view !== calendarViews.AGENDA &&
<div className={styles.calendarContent}>
<CalendarHeaderConnector />
<DaysOfWeekConnector />
<CalendarDaysConnector />
</div>
}
</div>
);
}
}
Calendar.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
view: PropTypes.string.isRequired
};
export default Calendar;

View File

@@ -1,170 +0,0 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Episode from 'Episode/Episode';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds } from 'Helpers/Props';
import {
clearCalendar,
fetchCalendar,
gotoCalendarToday,
} from 'Store/Actions/calendarActions';
import {
clearEpisodeFiles,
fetchEpisodeFiles,
} from 'Store/Actions/episodeFileActions';
import {
clearQueueDetails,
fetchQueueDetails,
} from 'Store/Actions/queueActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import {
registerPagePopulator,
unregisterPagePopulator,
} from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate';
import Agenda from './Agenda/Agenda';
import CalendarDays from './Day/CalendarDays';
import DaysOfWeek from './Day/DaysOfWeek';
import CalendarHeader from './Header/CalendarHeader';
import styles from './Calendar.css';
const UPDATE_DELAY = 3600000; // 1 hour
function Calendar() {
const dispatch = useDispatch();
const requestCurrentPage = useCurrentPage();
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
const { isFetching, isPopulated, error, items, time, view } = useSelector(
(state: AppState) => state.calendar
);
const isRefreshingSeries = useSelector(
createCommandExecutingSelector(commandNames.REFRESH_SERIES)
);
const firstDayOfWeek = useSelector(
(state: AppState) => state.settings.ui.item.firstDayOfWeek
);
const wasRefreshingSeries = usePrevious(isRefreshingSeries);
const previousFirstDayOfWeek = usePrevious(firstDayOfWeek);
const previousItems = usePrevious(items);
const handleScheduleUpdate = useCallback(() => {
clearTimeout(updateTimeout.current);
function updateCalendar() {
dispatch(gotoCalendarToday());
updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
}
updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
}, [dispatch]);
useEffect(() => {
handleScheduleUpdate();
return () => {
dispatch(clearCalendar());
dispatch(clearQueueDetails());
dispatch(clearEpisodeFiles());
clearTimeout(updateTimeout.current);
};
}, [dispatch, handleScheduleUpdate]);
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchCalendar());
} else {
dispatch(gotoCalendarToday());
}
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const repopulate = () => {
dispatch(fetchQueueDetails({ time, view }));
dispatch(fetchCalendar({ time, view }));
};
registerPagePopulator(repopulate, [
'episodeFileUpdated',
'episodeFileDeleted',
]);
return () => {
unregisterPagePopulator(repopulate);
};
}, [time, view, dispatch]);
useEffect(() => {
handleScheduleUpdate();
}, [time, handleScheduleUpdate]);
useEffect(() => {
if (
previousFirstDayOfWeek != null &&
firstDayOfWeek !== previousFirstDayOfWeek
) {
dispatch(fetchCalendar({ time, view }));
}
}, [time, view, firstDayOfWeek, previousFirstDayOfWeek, dispatch]);
useEffect(() => {
if (wasRefreshingSeries && !isRefreshingSeries) {
dispatch(fetchCalendar({ time, view }));
}
}, [time, view, isRefreshingSeries, wasRefreshingSeries, dispatch]);
useEffect(() => {
if (!previousItems || hasDifferentItems(items, previousItems)) {
const episodeIds = selectUniqueIds<Episode, number>(items, 'id');
const episodeFileIds = selectUniqueIds<Episode, number>(
items,
'episodeFileId'
);
if (items.length) {
dispatch(fetchQueueDetails({ episodeIds }));
}
if (episodeFileIds.length) {
dispatch(fetchEpisodeFiles({ episodeFileIds }));
}
}
}, [items, previousItems, dispatch]);
return (
<div className={styles.calendar}>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
) : null}
{!error && isPopulated && view === 'agenda' ? (
<div className={styles.calendarContent}>
<CalendarHeader />
<Agenda />
</div>
) : null}
{!error && isPopulated && view !== 'agenda' ? (
<div className={styles.calendarContent}>
<CalendarHeader />
<DaysOfWeek />
<CalendarDays />
</div>
) : null}
</div>
);
}
export default Calendar;

View File

@@ -0,0 +1,196 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import * as calendarActions from 'Store/Actions/calendarActions';
import { clearEpisodeFiles, fetchEpisodeFiles } from 'Store/Actions/episodeFileActions';
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import Calendar from './Calendar';
const UPDATE_DELAY = 3600000; // 1 hour
function createMapStateToProps() {
return createSelector(
(state) => state.calendar,
(state) => state.settings.ui.item.firstDayOfWeek,
createCommandExecutingSelector(commandNames.REFRESH_SERIES),
(calendar, firstDayOfWeek, isRefreshingSeries) => {
return {
...calendar,
isRefreshingSeries,
firstDayOfWeek
};
}
);
}
const mapDispatchToProps = {
...calendarActions,
fetchEpisodeFiles,
clearEpisodeFiles,
fetchQueueDetails,
clearQueueDetails
};
class CalendarConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.updateTimeoutId = null;
}
componentDidMount() {
const {
useCurrentPage,
fetchCalendar,
gotoCalendarToday
} = this.props;
registerPagePopulator(this.repopulate, ['episodeFileUpdated', 'episodeFileDeleted']);
if (useCurrentPage) {
fetchCalendar();
} else {
gotoCalendarToday();
}
this.scheduleUpdate();
}
componentDidUpdate(prevProps) {
const {
items,
time,
view,
isRefreshingSeries,
firstDayOfWeek
} = this.props;
if (hasDifferentItems(prevProps.items, items)) {
const episodeIds = selectUniqueIds(items, 'id');
const episodeFileIds = selectUniqueIds(items, 'episodeFileId');
if (items.length) {
this.props.fetchQueueDetails({ episodeIds });
}
if (episodeFileIds.length) {
this.props.fetchEpisodeFiles({ episodeFileIds });
}
}
if (prevProps.time !== time) {
this.scheduleUpdate();
}
if (prevProps.firstDayOfWeek !== firstDayOfWeek) {
this.props.fetchCalendar({ time, view });
}
if (prevProps.isRefreshingSeries && !isRefreshingSeries) {
this.props.fetchCalendar({ time, view });
}
}
componentWillUnmount() {
unregisterPagePopulator(this.repopulate);
this.props.clearCalendar();
this.props.clearQueueDetails();
this.props.clearEpisodeFiles();
this.clearUpdateTimeout();
}
//
// Control
repopulate = () => {
const {
time,
view
} = this.props;
this.props.fetchQueueDetails({ time, view });
this.props.fetchCalendar({ time, view });
};
scheduleUpdate = () => {
this.clearUpdateTimeout();
this.updateTimeoutId = setTimeout(this.updateCalendar, UPDATE_DELAY);
};
clearUpdateTimeout = () => {
if (this.updateTimeoutId) {
clearTimeout(this.updateTimeoutId);
}
};
updateCalendar = () => {
this.props.gotoCalendarToday();
this.scheduleUpdate();
};
//
// Listeners
onCalendarViewChange = (view) => {
this.props.setCalendarView({ view });
};
onTodayPress = () => {
this.props.gotoCalendarToday();
};
onPreviousPress = () => {
this.props.gotoCalendarPreviousRange();
};
onNextPress = () => {
this.props.gotoCalendarNextRange();
};
//
// Render
render() {
return (
<Calendar
{...this.props}
onCalendarViewChange={this.onCalendarViewChange}
onTodayPress={this.onTodayPress}
onPreviousPress={this.onPreviousPress}
onNextPress={this.onNextPress}
/>
);
}
}
CalendarConnector.propTypes = {
useCurrentPage: PropTypes.bool.isRequired,
time: PropTypes.string,
view: PropTypes.string.isRequired,
firstDayOfWeek: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
isRefreshingSeries: PropTypes.bool.isRequired,
setCalendarView: PropTypes.func.isRequired,
gotoCalendarToday: PropTypes.func.isRequired,
gotoCalendarPreviousRange: PropTypes.func.isRequired,
gotoCalendarNextRange: PropTypes.func.isRequired,
clearCalendar: PropTypes.func.isRequired,
fetchCalendar: PropTypes.func.isRequired,
fetchEpisodeFiles: PropTypes.func.isRequired,
clearEpisodeFiles: PropTypes.func.isRequired,
fetchQueueDetails: PropTypes.func.isRequired,
clearQueueDetails: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarConnector);

View File

@@ -0,0 +1,197 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Measure from 'Components/Measure';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { align, icons } from 'Helpers/Props';
import NoSeries from 'Series/NoSeries';
import translate from 'Utilities/String/translate';
import CalendarConnector from './CalendarConnector';
import CalendarFilterModal from './CalendarFilterModal';
import CalendarLinkModal from './iCal/CalendarLinkModal';
import LegendConnector from './Legend/LegendConnector';
import CalendarOptionsModal from './Options/CalendarOptionsModal';
import styles from './CalendarPage.css';
const MINIMUM_DAY_WIDTH = 120;
class CalendarPage extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isCalendarLinkModalOpen: false,
isOptionsModalOpen: false,
width: 0
};
}
//
// Listeners
onMeasure = ({ width }) => {
this.setState({ width });
const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH)));
this.props.onDaysCountChange(days);
};
onGetCalendarLinkPress = () => {
this.setState({ isCalendarLinkModalOpen: true });
};
onGetCalendarLinkModalClose = () => {
this.setState({ isCalendarLinkModalOpen: false });
};
onOptionsPress = () => {
this.setState({ isOptionsModalOpen: true });
};
onOptionsModalClose = () => {
this.setState({ isOptionsModalOpen: false });
};
onSearchMissingPress = () => {
const {
missingEpisodeIds,
onSearchMissingPress
} = this.props;
onSearchMissingPress(missingEpisodeIds);
};
//
// Render
render() {
const {
selectedFilterKey,
filters,
customFilters,
hasSeries,
missingEpisodeIds,
isRssSyncExecuting,
isSearchingForMissing,
useCurrentPage,
onRssSyncPress,
onFilterSelect
} = this.props;
const {
isCalendarLinkModalOpen,
isOptionsModalOpen
} = this.state;
const isMeasured = this.state.width > 0;
const PageComponent = hasSeries ? CalendarConnector : NoSeries;
return (
<PageContent title={translate('Calendar')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('ICalLink')}
iconName={icons.CALENDAR}
onPress={this.onGetCalendarLinkPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('RssSync')}
iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
onPress={onRssSyncPress}
/>
<PageToolbarButton
label={translate('SearchForMissing')}
iconName={icons.SEARCH}
isDisabled={!missingEpisodeIds.length}
isSpinning={isSearchingForMissing}
onPress={this.onSearchMissingPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton
label={translate('Options')}
iconName={icons.POSTER}
onPress={this.onOptionsPress}
/>
<FilterMenu
alignMenu={align.RIGHT}
isDisabled={!hasSeries}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={CalendarFilterModal}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody
className={styles.calendarPageBody}
innerClassName={styles.calendarInnerPageBody}
>
<Measure
whitelist={['width']}
onMeasure={this.onMeasure}
>
{
isMeasured ?
<PageComponent
useCurrentPage={useCurrentPage}
/> :
<div />
}
</Measure>
{
hasSeries &&
<LegendConnector />
}
</PageContentBody>
<CalendarLinkModal
isOpen={isCalendarLinkModalOpen}
onModalClose={this.onGetCalendarLinkModalClose}
/>
<CalendarOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={this.onOptionsModalClose}
/>
</PageContent>
);
}
}
CalendarPage.propTypes = {
selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
hasSeries: PropTypes.bool.isRequired,
missingEpisodeIds: PropTypes.arrayOf(PropTypes.number).isRequired,
isRssSyncExecuting: PropTypes.bool.isRequired,
isSearchingForMissing: PropTypes.bool.isRequired,
useCurrentPage: PropTypes.bool.isRequired,
onSearchMissingPress: PropTypes.func.isRequired,
onDaysCountChange: PropTypes.func.isRequired,
onRssSyncPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired
};
export default CalendarPage;

View File

@@ -1,226 +0,0 @@
import moment from 'moment';
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Measure from 'Components/Measure';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { align, icons } from 'Helpers/Props';
import NoSeries from 'Series/NoSeries';
import {
searchMissing,
setCalendarDaysCount,
setCalendarFilter,
} from 'Store/Actions/calendarActions';
import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
import { isCommandExecuting } from 'Utilities/Command';
import isBefore from 'Utilities/Date/isBefore';
import translate from 'Utilities/String/translate';
import Calendar from './Calendar';
import CalendarFilterModal from './CalendarFilterModal';
import CalendarLinkModal from './iCal/CalendarLinkModal';
import Legend from './Legend/Legend';
import CalendarOptionsModal from './Options/CalendarOptionsModal';
import styles from './CalendarPage.css';
const MINIMUM_DAY_WIDTH = 120;
function createMissingEpisodeIdsSelector() {
return createSelector(
(state: AppState) => state.calendar.start,
(state: AppState) => state.calendar.end,
(state: AppState) => state.calendar.items,
(state: AppState) => state.queue.details.items,
(start, end, episodes, queueDetails) => {
return episodes.reduce<number[]>((acc, episode) => {
const airDateUtc = episode.airDateUtc;
if (
!episode.episodeFileId &&
moment(airDateUtc).isAfter(start) &&
moment(airDateUtc).isBefore(end) &&
isBefore(episode.airDateUtc) &&
!queueDetails.some(
(details) => !!details.episode && details.episode.id === episode.id
)
) {
acc.push(episode.id);
}
return acc;
}, []);
}
);
}
function createIsSearchingSelector() {
return createSelector(
(state: AppState) => state.calendar.searchMissingCommandId,
createCommandsSelector(),
(searchMissingCommandId, commands) => {
if (searchMissingCommandId == null) {
return false;
}
return isCommandExecuting(
commands.find((command) => {
return command.id === searchMissingCommandId;
})
);
}
);
}
function CalendarPage() {
const dispatch = useDispatch();
const { selectedFilterKey, filters } = useSelector(
(state: AppState) => state.calendar
);
const missingEpisodeIds = useSelector(createMissingEpisodeIdsSelector());
const isSearchingForMissing = useSelector(createIsSearchingSelector());
const isRssSyncExecuting = useSelector(
createCommandExecutingSelector(commandNames.RSS_SYNC)
);
const customFilters = useSelector(createCustomFiltersSelector('calendar'));
const hasSeries = !!useSelector(createSeriesCountSelector());
const [isCalendarLinkModalOpen, setIsCalendarLinkModalOpen] = useState(false);
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
const [width, setWidth] = useState(0);
const isMeasured = width > 0;
const PageComponent = hasSeries ? Calendar : NoSeries;
const handleMeasure = useCallback(
({ width: newWidth }: { width: number }) => {
setWidth(newWidth);
const dayCount = Math.max(
3,
Math.min(7, Math.floor(newWidth / MINIMUM_DAY_WIDTH))
);
dispatch(setCalendarDaysCount({ dayCount }));
},
[dispatch]
);
const handleGetCalendarLinkPress = useCallback(() => {
setIsCalendarLinkModalOpen(true);
}, []);
const handleGetCalendarLinkModalClose = useCallback(() => {
setIsCalendarLinkModalOpen(false);
}, []);
const handleOptionsPress = useCallback(() => {
setIsOptionsModalOpen(true);
}, []);
const handleOptionsModalClose = useCallback(() => {
setIsOptionsModalOpen(false);
}, []);
const handleRssSyncPress = useCallback(() => {
dispatch(
executeCommand({
name: commandNames.RSS_SYNC,
})
);
}, [dispatch]);
const handleSearchMissingPress = useCallback(() => {
dispatch(searchMissing({ episodeIds: missingEpisodeIds }));
}, [missingEpisodeIds, dispatch]);
const handleFilterSelect = useCallback(
(key: string) => {
dispatch(setCalendarFilter({ selectedFilterKey: key }));
},
[dispatch]
);
return (
<PageContent title={translate('Calendar')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('ICalLink')}
iconName={icons.CALENDAR}
onPress={handleGetCalendarLinkPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('RssSync')}
iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
onPress={handleRssSyncPress}
/>
<PageToolbarButton
label={translate('SearchForMissing')}
iconName={icons.SEARCH}
isDisabled={!missingEpisodeIds.length}
isSpinning={isSearchingForMissing}
onPress={handleSearchMissingPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton
label={translate('Options')}
iconName={icons.POSTER}
onPress={handleOptionsPress}
/>
<FilterMenu
alignMenu={align.RIGHT}
isDisabled={!hasSeries}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={CalendarFilterModal}
onFilterSelect={handleFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody
className={styles.calendarPageBody}
innerClassName={styles.calendarInnerPageBody}
>
<Measure whitelist={['width']} onMeasure={handleMeasure}>
{isMeasured ? <PageComponent totalItems={0} /> : <div />}
</Measure>
{hasSeries && <Legend />}
</PageContentBody>
<CalendarLinkModal
isOpen={isCalendarLinkModalOpen}
onModalClose={handleGetCalendarLinkModalClose}
/>
<CalendarOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={handleOptionsModalClose}
/>
</PageContent>
);
}
export default CalendarPage;

View File

@@ -0,0 +1,117 @@
import moment from 'moment';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import withCurrentPage from 'Components/withCurrentPage';
import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions';
import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { isCommandExecuting } from 'Utilities/Command';
import isBefore from 'Utilities/Date/isBefore';
import CalendarPage from './CalendarPage';
function createMissingEpisodeIdsSelector() {
return createSelector(
(state) => state.calendar.start,
(state) => state.calendar.end,
(state) => state.calendar.items,
(state) => state.queue.details.items,
(start, end, episodes, queueDetails) => {
return episodes.reduce((acc, episode) => {
const airDateUtc = episode.airDateUtc;
if (
!episode.episodeFileId &&
moment(airDateUtc).isAfter(start) &&
moment(airDateUtc).isBefore(end) &&
isBefore(episode.airDateUtc) &&
!queueDetails.some((details) => !!details.episode && details.episode.id === episode.id)
) {
acc.push(episode.id);
}
return acc;
}, []);
}
);
}
function createIsSearchingSelector() {
return createSelector(
(state) => state.calendar.searchMissingCommandId,
createCommandsSelector(),
(searchMissingCommandId, commands) => {
if (searchMissingCommandId == null) {
return false;
}
return isCommandExecuting(commands.find((command) => {
return command.id === searchMissingCommandId;
}));
}
);
}
function createMapStateToProps() {
return createSelector(
(state) => state.calendar.selectedFilterKey,
(state) => state.calendar.filters,
createCustomFiltersSelector('calendar'),
createSeriesCountSelector(),
createUISettingsSelector(),
createMissingEpisodeIdsSelector(),
createCommandExecutingSelector(commandNames.RSS_SYNC),
createIsSearchingSelector(),
(
selectedFilterKey,
filters,
customFilters,
seriesCount,
uiSettings,
missingEpisodeIds,
isRssSyncExecuting,
isSearchingForMissing
) => {
return {
selectedFilterKey,
filters,
customFilters,
colorImpairedMode: uiSettings.enableColorImpairedMode,
hasSeries: !!seriesCount,
missingEpisodeIds,
isRssSyncExecuting,
isSearchingForMissing
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onRssSyncPress() {
dispatch(executeCommand({
name: commandNames.RSS_SYNC
}));
},
onSearchMissingPress(episodeIds) {
dispatch(searchMissing({ episodeIds }));
},
onDaysCountChange(dayCount) {
dispatch(setCalendarDaysCount({ dayCount }));
},
onFilterSelect(selectedFilterKey) {
dispatch(setCalendarFilter({ selectedFilterKey }));
}
};
}
export default withCurrentPage(
connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage)
);

View File

@@ -1,104 +1,25 @@
import classNames from 'classnames';
import moment from 'moment';
import React from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import * as calendarViews from 'Calendar/calendarViews';
import CalendarEvent from 'Calendar/Events/CalendarEvent';
import CalendarEventGroup from 'Calendar/Events/CalendarEventGroup';
import {
CalendarEvent as CalendarEventModel,
CalendarEventGroup as CalendarEventGroupModel,
CalendarItem,
} from 'typings/Calendar';
import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
import CalendarEventGroupConnector from 'Calendar/Events/CalendarEventGroupConnector';
import Series from 'Series/Series';
import CalendarEventGroup, { CalendarEvent } from 'typings/CalendarEventGroup';
import styles from './CalendarDay.css';
function sort(items: (CalendarEventModel | CalendarEventGroupModel)[]) {
return items.sort((a, b) => {
const aDate = a.isGroup
? moment(a.events[0].airDateUtc).unix()
: moment(a.airDateUtc).unix();
const bDate = b.isGroup
? moment(b.events[0].airDateUtc).unix()
: moment(b.airDateUtc).unix();
return aDate - bDate;
});
}
function createCalendarEventsConnector(date: string) {
return createSelector(
(state: AppState) => state.calendar.items,
(state: AppState) => state.calendar.options.collapseMultipleEpisodes,
(items, collapseMultipleEpisodes) => {
const momentDate = moment(date);
const filtered = items.filter((item) => {
return momentDate.isSame(moment(item.airDateUtc), 'day');
});
if (!collapseMultipleEpisodes) {
return sort(
filtered.map((item) => ({
isGroup: false,
...item,
}))
);
}
const groupedObject = Object.groupBy(
filtered,
(item: CalendarItem) => `${item.seriesId}-${item.seasonNumber}`
);
const grouped = Object.entries(groupedObject).reduce<
(CalendarEventModel | CalendarEventGroupModel)[]
>((acc, [, events]) => {
if (!events) {
return acc;
}
if (events.length === 1) {
acc.push({
isGroup: false,
...events[0],
});
} else {
acc.push({
isGroup: true,
seriesId: events[0].seriesId,
seasonNumber: events[0].seasonNumber,
episodeIds: events.map((event) => event.id),
events: events.sort(
(a, b) =>
moment(a.airDateUtc).unix() - moment(b.airDateUtc).unix()
),
});
}
return acc;
}, []);
return sort(grouped);
}
);
}
interface CalendarDayProps {
date: string;
time: string;
isTodaysDate: boolean;
onEventModalOpenToggle(isOpen: boolean): unknown;
events: (CalendarEvent | CalendarEventGroup)[];
view: string;
onEventModalOpenToggle(...args: unknown[]): unknown;
}
function CalendarDay({
date,
isTodaysDate,
onEventModalOpenToggle,
}: CalendarDayProps) {
const { time, view } = useSelector((state: AppState) => state.calendar);
const events = useSelector(createCalendarEventsConnector(date));
function CalendarDay(props: CalendarDayProps) {
const { date, time, isTodaysDate, events, view, onEventModalOpenToggle } =
props;
const ref = React.useRef<HTMLDivElement>(null);
@@ -132,7 +53,7 @@ function CalendarDay({
{events.map((event) => {
if (event.isGroup) {
return (
<CalendarEventGroup
<CalendarEventGroupConnector
key={event.seriesId}
{...event}
onEventModalOpenToggle={onEventModalOpenToggle}
@@ -141,11 +62,11 @@ function CalendarDay({
}
return (
<CalendarEvent
<CalendarEventConnector
key={event.id}
{...event}
episodeId={event.id}
seriesId={event.seriesId}
series={event.series as Series}
airDateUtc={event.airDateUtc as string}
onEventModalOpenToggle={onEventModalOpenToggle}
/>

View File

@@ -0,0 +1,91 @@
import _ from 'lodash';
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import CalendarDay from './CalendarDay';
function sort(items) {
return _.sortBy(items, (item) => {
if (item.isGroup) {
return moment(item.events[0].airDateUtc).unix();
}
return moment(item.airDateUtc).unix();
});
}
function createCalendarEventsConnector() {
return createSelector(
(state, { date }) => date,
(state) => state.calendar.items,
(state) => state.calendar.options.collapseMultipleEpisodes,
(date, items, collapseMultipleEpisodes) => {
const filtered = _.filter(items, (item) => {
return moment(date).isSame(moment(item.airDateUtc), 'day');
});
if (!collapseMultipleEpisodes) {
return sort(filtered);
}
const groupedObject = _.groupBy(filtered, (item) => `${item.seriesId}-${item.seasonNumber}`);
const grouped = [];
Object.keys(groupedObject).forEach((key) => {
const events = groupedObject[key];
if (events.length === 1) {
grouped.push(events[0]);
} else {
grouped.push({
isGroup: true,
seriesId: events[0].seriesId,
seasonNumber: events[0].seasonNumber,
episodeIds: events.map((event) => event.id),
events: _.sortBy(events, (item) => moment(item.airDateUtc).unix())
});
}
});
const sorted = sort(grouped);
return sorted;
}
);
}
function createMapStateToProps() {
return createSelector(
(state) => state.calendar,
createCalendarEventsConnector(),
(calendar, events) => {
return {
time: calendar.time,
view: calendar.view,
events
};
}
);
}
class CalendarDayConnector extends Component {
//
// Render
render() {
return (
<CalendarDay
{...this.props}
/>
);
}
}
CalendarDayConnector.propTypes = {
date: PropTypes.string.isRequired
};
export default connect(createMapStateToProps)(CalendarDayConnector);

View File

@@ -0,0 +1,164 @@
import classNames from 'classnames';
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import * as calendarViews from 'Calendar/calendarViews';
import isToday from 'Utilities/Date/isToday';
import CalendarDayConnector from './CalendarDayConnector';
import styles from './CalendarDays.css';
class CalendarDays extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._touchStart = null;
this.state = {
todaysDate: moment().startOf('day').toISOString(),
isEventModalOpen: false
};
this.updateTimeoutId = null;
}
// Lifecycle
componentDidMount() {
const view = this.props.view;
if (view === calendarViews.MONTH) {
this.scheduleUpdate();
}
window.addEventListener('touchstart', this.onTouchStart);
window.addEventListener('touchend', this.onTouchEnd);
window.addEventListener('touchcancel', this.onTouchCancel);
window.addEventListener('touchmove', this.onTouchMove);
}
componentWillUnmount() {
this.clearUpdateTimeout();
window.removeEventListener('touchstart', this.onTouchStart);
window.removeEventListener('touchend', this.onTouchEnd);
window.removeEventListener('touchcancel', this.onTouchCancel);
window.removeEventListener('touchmove', this.onTouchMove);
}
//
// Control
scheduleUpdate = () => {
this.clearUpdateTimeout();
const todaysDate = moment().startOf('day');
const diff = moment().diff(todaysDate.clone().add(1, 'day'));
this.setState({ todaysDate: todaysDate.toISOString() });
this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
};
clearUpdateTimeout = () => {
if (this.updateTimeoutId) {
clearTimeout(this.updateTimeoutId);
}
};
//
// Listeners
onEventModalOpenToggle = (isEventModalOpen) => {
this.setState({ isEventModalOpen });
};
onTouchStart = (event) => {
const touches = event.touches;
const touchStart = touches[0].pageX;
if (touches.length !== 1) {
return;
}
if (
touchStart < 50 ||
this.props.isSidebarVisible ||
this.state.isEventModalOpen
) {
return;
}
this._touchStart = touchStart;
};
onTouchEnd = (event) => {
const touches = event.changedTouches;
const currentTouch = touches[0].pageX;
if (!this._touchStart) {
return;
}
if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) {
this.props.onNavigatePrevious();
} else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) {
this.props.onNavigateNext();
}
this._touchStart = null;
};
onTouchCancel = (event) => {
this._touchStart = null;
};
onTouchMove = (event) => {
if (!this._touchStart) {
return;
}
};
//
// Render
render() {
const {
dates,
view
} = this.props;
return (
<div className={classNames(
styles.days,
styles[view]
)}
>
{
dates.map((date) => {
return (
<CalendarDayConnector
key={date}
date={date}
isTodaysDate={isToday(date)}
onEventModalOpenToggle={this.onEventModalOpenToggle}
/>
);
})
}
</div>
);
}
}
CalendarDays.propTypes = {
dates: PropTypes.arrayOf(PropTypes.string).isRequired,
view: PropTypes.string.isRequired,
isSidebarVisible: PropTypes.bool.isRequired,
onNavigatePrevious: PropTypes.func.isRequired,
onNavigateNext: PropTypes.func.isRequired
};
export default CalendarDays;

View File

@@ -1,135 +0,0 @@
import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as calendarViews from 'Calendar/calendarViews';
import {
gotoCalendarNextRange,
gotoCalendarPreviousRange,
} from 'Store/Actions/calendarActions';
import CalendarDay from './CalendarDay';
import styles from './CalendarDays.css';
function CalendarDays() {
const dispatch = useDispatch();
const { dates, view } = useSelector((state: AppState) => state.calendar);
const isSidebarVisible = useSelector(
(state: AppState) => state.app.isSidebarVisible
);
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
const touchStart = useRef<number | null>(null);
const isEventModalOpen = useRef(false);
const [todaysDate, setTodaysDate] = useState(
moment().startOf('day').toISOString()
);
const handleEventModalOpenToggle = useCallback((isOpen: boolean) => {
isEventModalOpen.current = isOpen;
}, []);
const scheduleUpdate = useCallback(() => {
clearTimeout(updateTimeout.current);
const todaysDate = moment().startOf('day');
const diff = moment().diff(todaysDate.clone().add(1, 'day'));
setTodaysDate(todaysDate.toISOString());
updateTimeout.current = setTimeout(scheduleUpdate, diff);
}, []);
const handleTouchStart = useCallback(
(event: TouchEvent) => {
const touches = event.touches;
const currentTouch = touches[0].pageX;
if (touches.length !== 1) {
return;
}
if (currentTouch < 50 || isSidebarVisible || isEventModalOpen.current) {
return;
}
touchStart.current = currentTouch;
},
[isSidebarVisible]
);
const handleTouchEnd = useCallback(
(event: TouchEvent) => {
const touches = event.changedTouches;
const currentTouch = touches[0].pageX;
if (!touchStart.current) {
return;
}
if (
currentTouch > touchStart.current &&
currentTouch - touchStart.current > 100
) {
dispatch(gotoCalendarPreviousRange());
} else if (
currentTouch < touchStart.current &&
touchStart.current - currentTouch > 100
) {
dispatch(gotoCalendarNextRange());
}
touchStart.current = null;
},
[dispatch]
);
const handleTouchCancel = useCallback(() => {
touchStart.current = null;
}, []);
const handleTouchMove = useCallback(() => {
if (!touchStart.current) {
return;
}
}, []);
useEffect(() => {
if (view === calendarViews.MONTH) {
scheduleUpdate();
}
}, [view, scheduleUpdate]);
useEffect(() => {
window.addEventListener('touchstart', handleTouchStart);
window.addEventListener('touchend', handleTouchEnd);
window.addEventListener('touchcancel', handleTouchCancel);
window.addEventListener('touchmove', handleTouchMove);
return () => {
window.removeEventListener('touchstart', handleTouchStart);
window.removeEventListener('touchend', handleTouchEnd);
window.removeEventListener('touchcancel', handleTouchCancel);
window.removeEventListener('touchmove', handleTouchMove);
};
}, [handleTouchStart, handleTouchEnd, handleTouchCancel, handleTouchMove]);
return (
<div
className={classNames(styles.days, styles[view as keyof typeof styles])}
>
{dates.map((date) => {
return (
<CalendarDay
key={date}
date={date}
isTodaysDate={date === todaysDate}
onEventModalOpenToggle={handleEventModalOpenToggle}
/>
);
})}
</div>
);
}
export default CalendarDays;

View File

@@ -0,0 +1,25 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { gotoCalendarNextRange, gotoCalendarPreviousRange } from 'Store/Actions/calendarActions';
import CalendarDays from './CalendarDays';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar,
(state) => state.app.isSidebarVisible,
(calendar, isSidebarVisible) => {
return {
dates: calendar.dates,
view: calendar.view,
isSidebarVisible
};
}
);
}
const mapDispatchToProps = {
onNavigatePrevious: gotoCalendarPreviousRange,
onNavigateNext: gotoCalendarNextRange
};
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarDays);

View File

@@ -0,0 +1,56 @@
import classNames from 'classnames';
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import * as calendarViews from 'Calendar/calendarViews';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import styles from './DayOfWeek.css';
class DayOfWeek extends Component {
//
// Render
render() {
const {
date,
view,
isTodaysDate,
calendarWeekColumnHeader,
shortDateFormat,
showRelativeDates
} = this.props;
const highlightToday = view !== calendarViews.MONTH && isTodaysDate;
const momentDate = moment(date);
let formatedDate = momentDate.format('dddd');
if (view === calendarViews.WEEK) {
formatedDate = momentDate.format(calendarWeekColumnHeader);
} else if (view === calendarViews.FORECAST) {
formatedDate = getRelativeDate({ date, shortDateFormat, showRelativeDates });
}
return (
<div className={classNames(
styles.dayOfWeek,
view === calendarViews.DAY && styles.isSingleDay,
highlightToday && styles.isToday
)}
>
{formatedDate}
</div>
);
}
}
DayOfWeek.propTypes = {
date: PropTypes.string.isRequired,
view: PropTypes.string.isRequired,
isTodaysDate: PropTypes.bool.isRequired,
calendarWeekColumnHeader: PropTypes.string.isRequired,
shortDateFormat: PropTypes.string.isRequired,
showRelativeDates: PropTypes.bool.isRequired
};
export default DayOfWeek;

View File

@@ -1,54 +0,0 @@
import classNames from 'classnames';
import moment from 'moment';
import React from 'react';
import * as calendarViews from 'Calendar/calendarViews';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import styles from './DayOfWeek.css';
interface DayOfWeekProps {
date: string;
view: string;
isTodaysDate: boolean;
calendarWeekColumnHeader: string;
shortDateFormat: string;
showRelativeDates: boolean;
}
function DayOfWeek(props: DayOfWeekProps) {
const {
date,
view,
isTodaysDate,
calendarWeekColumnHeader,
shortDateFormat,
showRelativeDates,
} = props;
const highlightToday = view !== calendarViews.MONTH && isTodaysDate;
const momentDate = moment(date);
let formatedDate = momentDate.format('dddd');
if (view === calendarViews.WEEK) {
formatedDate = momentDate.format(calendarWeekColumnHeader);
} else if (view === calendarViews.FORECAST) {
formatedDate = getRelativeDate({
date,
shortDateFormat,
showRelativeDates,
});
}
return (
<div
className={classNames(
styles.dayOfWeek,
view === calendarViews.DAY && styles.isSingleDay,
highlightToday && styles.isToday
)}
>
{formatedDate}
</div>
);
}
export default DayOfWeek;

View File

@@ -0,0 +1,97 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import * as calendarViews from 'Calendar/calendarViews';
import DayOfWeek from './DayOfWeek';
import styles from './DaysOfWeek.css';
class DaysOfWeek extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
todaysDate: moment().startOf('day').toISOString()
};
this.updateTimeoutId = null;
}
// Lifecycle
componentDidMount() {
const view = this.props.view;
if (view !== calendarViews.AGENDA || view !== calendarViews.MONTH) {
this.scheduleUpdate();
}
}
componentWillUnmount() {
this.clearUpdateTimeout();
}
//
// Control
scheduleUpdate = () => {
this.clearUpdateTimeout();
const todaysDate = moment().startOf('day');
const diff = todaysDate.clone().add(1, 'day').diff(moment());
this.setState({
todaysDate: todaysDate.toISOString()
});
this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
};
clearUpdateTimeout = () => {
if (this.updateTimeoutId) {
clearTimeout(this.updateTimeoutId);
}
};
//
// Render
render() {
const {
dates,
view,
...otherProps
} = this.props;
if (view === calendarViews.AGENDA) {
return null;
}
return (
<div className={styles.daysOfWeek}>
{
dates.map((date) => {
return (
<DayOfWeek
key={date}
date={date}
view={view}
isTodaysDate={date === this.state.todaysDate}
{...otherProps}
/>
);
})
}
</div>
);
}
}
DaysOfWeek.propTypes = {
dates: PropTypes.arrayOf(PropTypes.string),
view: PropTypes.string.isRequired
};
export default DaysOfWeek;

View File

@@ -1,60 +0,0 @@
import moment from 'moment';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as calendarViews from 'Calendar/calendarViews';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import DayOfWeek from './DayOfWeek';
import styles from './DaysOfWeek.css';
function DaysOfWeek() {
const { dates, view } = useSelector((state: AppState) => state.calendar);
const { calendarWeekColumnHeader, shortDateFormat, showRelativeDates } =
useSelector(createUISettingsSelector());
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
const [todaysDate, setTodaysDate] = useState(
moment().startOf('day').toISOString()
);
const scheduleUpdate = useCallback(() => {
clearTimeout(updateTimeout.current);
const todaysDate = moment().startOf('day');
const diff = moment().diff(todaysDate.clone().add(1, 'day'));
setTodaysDate(todaysDate.toISOString());
updateTimeout.current = setTimeout(scheduleUpdate, diff);
}, []);
useEffect(() => {
if (view !== calendarViews.AGENDA && view !== calendarViews.MONTH) {
scheduleUpdate();
}
}, [view, scheduleUpdate]);
if (view === calendarViews.AGENDA) {
return null;
}
return (
<div className={styles.daysOfWeek}>
{dates.map((date) => {
return (
<DayOfWeek
key={date}
date={date}
view={view}
isTodaysDate={date === todaysDate}
calendarWeekColumnHeader={calendarWeekColumnHeader}
shortDateFormat={shortDateFormat}
showRelativeDates={showRelativeDates}
/>
);
})}
</div>
);
}
export default DaysOfWeek;

View File

@@ -0,0 +1,22 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import DaysOfWeek from './DaysOfWeek';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar,
createUISettingsSelector(),
(calendar, UiSettings) => {
return {
dates: calendar.dates.slice(0, 7),
view: calendar.view,
calendarWeekColumnHeader: UiSettings.calendarWeekColumnHeader,
shortDateFormat: UiSettings.shortDateFormat,
showRelativeDates: UiSettings.showRelativeDates
};
}
);
}
export default connect(createMapStateToProps)(DaysOfWeek);

View File

@@ -0,0 +1,267 @@
import classNames from 'classnames';
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
import episodeEntities from 'Episode/episodeEntities';
import getFinaleTypeName from 'Episode/getFinaleTypeName';
import { icons, kinds } from 'Helpers/Props';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
import CalendarEventQueueDetails from './CalendarEventQueueDetails';
import styles from './CalendarEvent.css';
class CalendarEvent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false
};
}
//
// Listeners
onPress = () => {
this.setState({ isDetailsModalOpen: true }, () => {
this.props.onEventModalOpenToggle(true);
});
};
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false }, () => {
this.props.onEventModalOpenToggle(false);
});
};
//
// Render
render() {
const {
id,
series,
episodeFile,
title,
seasonNumber,
episodeNumber,
absoluteEpisodeNumber,
airDateUtc,
monitored,
unverifiedSceneNumbering,
finaleType,
hasFile,
grabbed,
queueItem,
showEpisodeInformation,
showFinaleIcon,
showSpecialIcon,
showCutoffUnmetIcon,
fullColorEvents,
timeFormat,
colorImpairedMode
} = this.props;
if (!series) {
return null;
}
const startTime = moment(airDateUtc);
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
const isDownloading = !!(queueItem || grabbed);
const isMonitored = series.monitored && monitored;
const statusStyle = getStatusStyle(hasFile, isDownloading, startTime, endTime, isMonitored);
const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
return (
<div
className={classNames(
styles.event,
styles[statusStyle],
colorImpairedMode && 'colorImpaired',
fullColorEvents && 'fullColor'
)}
>
<Link
className={styles.underlay}
onPress={this.onPress}
/>
<div className={styles.overlay} >
<div className={styles.info}>
<div className={styles.seriesTitle}>
{series.title}
</div>
<div
className={classNames(
styles.statusContainer,
fullColorEvents && 'fullColor'
)}
>
{
missingAbsoluteNumber ?
<Icon
className={styles.statusIcon}
name={icons.WARNING}
title={translate('EpisodeMissingAbsoluteNumber')}
/> :
null
}
{
unverifiedSceneNumbering && !missingAbsoluteNumber ?
<Icon
className={styles.statusIcon}
name={icons.WARNING}
title={translate('SceneNumberNotVerified')}
/> :
null
}
{
queueItem ?
<span className={styles.statusIcon}>
<CalendarEventQueueDetails
{...queueItem}
fullColorEvents={fullColorEvents}
/>
</span> :
null
}
{
!queueItem && grabbed ?
<Icon
className={styles.statusIcon}
name={icons.DOWNLOADING}
title={translate('EpisodeIsDownloading')}
/> :
null
}
{
showCutoffUnmetIcon &&
!!episodeFile &&
episodeFile.qualityCutoffNotMet ?
<Icon
className={styles.statusIcon}
name={icons.EPISODE_FILE}
kind={kinds.WARNING}
title={translate('QualityCutoffNotMet')}
/> :
null
}
{
episodeNumber === 1 && seasonNumber > 0 ?
<Icon
className={styles.statusIcon}
name={icons.PREMIERE}
kind={kinds.INFO}
title={seasonNumber === 1 ? translate('SeriesPremiere') : translate('SeasonPremiere')}
/> :
null
}
{
showFinaleIcon &&
finaleType ?
<Icon
className={styles.statusIcon}
name={finaleType === 'series' ? icons.FINALE_SERIES : icons.FINALE_SEASON}
kind={finaleType === 'series' ? kinds.DANGER : kinds.WARNING}
title={getFinaleTypeName(finaleType)}
/> :
null
}
{
showSpecialIcon &&
(episodeNumber === 0 || seasonNumber === 0) ?
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.PINK}
title={translate('Special')}
/> :
null
}
</div>
</div>
{
showEpisodeInformation ?
<div className={styles.episodeInfo}>
<div className={styles.episodeTitle}>
{title}
</div>
<div>
{seasonNumber}x{padNumber(episodeNumber, 2)}
{
series.seriesType === 'anime' && absoluteEpisodeNumber ?
<span className={styles.absoluteEpisodeNumber}>({absoluteEpisodeNumber})</span> : null
}
</div>
</div> :
null
}
<div className={styles.airTime}>
{formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
</div>
</div>
<EpisodeDetailsModal
isOpen={this.state.isDetailsModalOpen}
episodeId={id}
episodeEntity={episodeEntities.CALENDAR}
seriesId={series.id}
episodeTitle={title}
showOpenSeriesButton={true}
onModalClose={this.onDetailsModalClose}
/>
</div>
);
}
}
CalendarEvent.propTypes = {
id: PropTypes.number.isRequired,
episodeId: PropTypes.number.isRequired,
series: PropTypes.object.isRequired,
episodeFile: PropTypes.object,
title: PropTypes.string.isRequired,
seasonNumber: PropTypes.number.isRequired,
episodeNumber: PropTypes.number.isRequired,
absoluteEpisodeNumber: PropTypes.number,
airDateUtc: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
unverifiedSceneNumbering: PropTypes.bool,
finaleType: PropTypes.string,
hasFile: PropTypes.bool.isRequired,
grabbed: PropTypes.bool,
queueItem: PropTypes.object,
// These props come from the connector, not marked as required to appease TS for now.
showEpisodeInformation: PropTypes.bool,
showFinaleIcon: PropTypes.bool,
showSpecialIcon: PropTypes.bool,
showCutoffUnmetIcon: PropTypes.bool,
fullColorEvents: PropTypes.bool,
timeFormat: PropTypes.string,
colorImpairedMode: PropTypes.bool,
onEventModalOpenToggle: PropTypes.func
};
export default CalendarEvent;

View File

@@ -1,240 +0,0 @@
import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
import episodeEntities from 'Episode/episodeEntities';
import getFinaleTypeName from 'Episode/getFinaleTypeName';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
import CalendarEventQueueDetails from './CalendarEventQueueDetails';
import styles from './CalendarEvent.css';
interface CalendarEventProps {
id: number;
episodeId: number;
seriesId: number;
episodeFileId?: number;
title: string;
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber?: number;
airDateUtc: string;
monitored: boolean;
unverifiedSceneNumbering?: boolean;
finaleType?: string;
hasFile: boolean;
grabbed?: boolean;
onEventModalOpenToggle: (isOpen: boolean) => void;
}
function CalendarEvent(props: CalendarEventProps) {
const {
id,
seriesId,
episodeFileId,
title,
seasonNumber,
episodeNumber,
absoluteEpisodeNumber,
airDateUtc,
monitored,
unverifiedSceneNumbering,
finaleType,
hasFile,
grabbed,
onEventModalOpenToggle,
} = props;
const series = useSeries(seriesId);
const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useSelector(createQueueItemSelectorForHook(id));
const { timeFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector()
);
const {
showEpisodeInformation,
showFinaleIcon,
showSpecialIcon,
showCutoffUnmetIcon,
fullColorEvents,
} = useSelector((state: AppState) => state.calendar.options);
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const handlePress = useCallback(() => {
setIsDetailsModalOpen(true);
onEventModalOpenToggle(true);
}, [onEventModalOpenToggle]);
const handleDetailsModalClose = useCallback(() => {
setIsDetailsModalOpen(false);
onEventModalOpenToggle(false);
}, [onEventModalOpenToggle]);
if (!series) {
return null;
}
const startTime = moment(airDateUtc);
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
const isDownloading = !!(queueItem || grabbed);
const isMonitored = series.monitored && monitored;
const statusStyle = getStatusStyle(
hasFile,
isDownloading,
startTime,
endTime,
isMonitored
);
const missingAbsoluteNumber =
series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
return (
<div
className={classNames(
styles.event,
styles[statusStyle],
enableColorImpairedMode && 'colorImpaired',
fullColorEvents && 'fullColor'
)}
>
<Link className={styles.underlay} onPress={handlePress} />
<div className={styles.overlay}>
<div className={styles.info}>
<div className={styles.seriesTitle}>{series.title}</div>
<div
className={classNames(
styles.statusContainer,
fullColorEvents && 'fullColor'
)}
>
{missingAbsoluteNumber ? (
<Icon
className={styles.statusIcon}
name={icons.WARNING}
title={translate('EpisodeMissingAbsoluteNumber')}
/>
) : null}
{unverifiedSceneNumbering && !missingAbsoluteNumber ? (
<Icon
className={styles.statusIcon}
name={icons.WARNING}
title={translate('SceneNumberNotVerified')}
/>
) : null}
{queueItem ? (
<span className={styles.statusIcon}>
<CalendarEventQueueDetails {...queueItem} />
</span>
) : null}
{!queueItem && grabbed ? (
<Icon
className={styles.statusIcon}
name={icons.DOWNLOADING}
title={translate('EpisodeIsDownloading')}
/>
) : null}
{showCutoffUnmetIcon &&
!!episodeFile &&
episodeFile.qualityCutoffNotMet ? (
<Icon
className={styles.statusIcon}
name={icons.EPISODE_FILE}
kind={kinds.WARNING}
title={translate('QualityCutoffNotMet')}
/>
) : null}
{episodeNumber === 1 && seasonNumber > 0 ? (
<Icon
className={styles.statusIcon}
name={icons.PREMIERE}
kind={kinds.INFO}
title={
seasonNumber === 1
? translate('SeriesPremiere')
: translate('SeasonPremiere')
}
/>
) : null}
{showFinaleIcon && finaleType ? (
<Icon
className={styles.statusIcon}
name={
finaleType === 'series'
? icons.FINALE_SERIES
: icons.FINALE_SEASON
}
kind={finaleType === 'series' ? kinds.DANGER : kinds.WARNING}
title={getFinaleTypeName(finaleType)}
/>
) : null}
{showSpecialIcon && (episodeNumber === 0 || seasonNumber === 0) ? (
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.PINK}
title={translate('Special')}
/>
) : null}
</div>
</div>
{showEpisodeInformation ? (
<div className={styles.episodeInfo}>
<div className={styles.episodeTitle}>{title}</div>
<div>
{seasonNumber}x{padNumber(episodeNumber, 2)}
{series.seriesType === 'anime' && absoluteEpisodeNumber ? (
<span className={styles.absoluteEpisodeNumber}>
({absoluteEpisodeNumber})
</span>
) : null}
</div>
</div>
) : null}
<div className={styles.airTime}>
{formatTime(airDateUtc, timeFormat)} -{' '}
{formatTime(endTime.toISOString(), timeFormat, {
includeMinuteZero: true,
})}
</div>
</div>
<EpisodeDetailsModal
isOpen={isDetailsModalOpen}
episodeId={id}
episodeEntity={episodeEntities.CALENDAR}
seriesId={series.id}
episodeTitle={title}
showOpenSeriesButton={true}
onModalClose={handleDetailsModalClose}
/>
</div>
);
}
export default CalendarEvent;

View File

@@ -0,0 +1,29 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import CalendarEvent from './CalendarEvent';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar.options,
createSeriesSelector(),
createEpisodeFileSelector(),
createQueueItemSelector(),
createUISettingsSelector(),
(calendarOptions, series, episodeFile, queueItem, uiSettings) => {
return {
series,
episodeFile,
queueItem,
...calendarOptions,
timeFormat: uiSettings.timeFormat,
colorImpairedMode: uiSettings.enableColorImpairedMode
};
}
);
}
export default connect(createMapStateToProps)(CalendarEvent);

View File

@@ -0,0 +1,259 @@
import classNames from 'classnames';
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import getFinaleTypeName from 'Episode/getFinaleTypeName';
import { icons, kinds } from 'Helpers/Props';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
import styles from './CalendarEventGroup.css';
function getEventsInfo(series, events) {
let files = 0;
let queued = 0;
let monitored = 0;
let absoluteEpisodeNumbers = 0;
events.forEach((event) => {
if (event.episodeFileId) {
files++;
}
if (event.queued) {
queued++;
}
if (series.monitored && event.monitored) {
monitored++;
}
if (event.absoluteEpisodeNumber) {
absoluteEpisodeNumbers++;
}
});
return {
allDownloaded: files === events.length,
anyQueued: queued > 0,
anyMonitored: monitored > 0,
allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length
};
}
class CalendarEventGroup extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isExpanded: false
};
}
//
// Listeners
onExpandPress = () => {
this.setState({ isExpanded: !this.state.isExpanded });
};
//
// Render
render() {
const {
series,
events,
isDownloading,
showEpisodeInformation,
showFinaleIcon,
timeFormat,
fullColorEvents,
colorImpairedMode,
onEventModalOpenToggle
} = this.props;
const { isExpanded } = this.state;
const {
allDownloaded,
anyQueued,
anyMonitored,
allAbsoluteEpisodeNumbers
} = getEventsInfo(series, events);
const anyDownloading = isDownloading || anyQueued;
const firstEpisode = events[0];
const lastEpisode = events[events.length -1];
const airDateUtc = firstEpisode.airDateUtc;
const startTime = moment(airDateUtc);
const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
const seasonNumber = firstEpisode.seasonNumber;
const statusStyle = getStatusStyle(allDownloaded, anyDownloading, startTime, endTime, anyMonitored);
const isMissingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !allAbsoluteEpisodeNumbers;
if (isExpanded) {
return (
<div>
{
events.map((event) => {
if (event.isGroup) {
return null;
}
return (
<CalendarEventConnector
key={event.id}
episodeId={event.id}
{...event}
onEventModalOpenToggle={onEventModalOpenToggle}
/>
);
})
}
<Link
className={styles.collapseContainer}
component="div"
onPress={this.onExpandPress}
>
<Icon
name={icons.COLLAPSE}
/>
</Link>
</div>
);
}
return (
<div
className={classNames(
styles.eventGroup,
styles[statusStyle],
colorImpairedMode && 'colorImpaired',
fullColorEvents && 'fullColor'
)}
>
<div className={styles.info}>
<div className={styles.seriesTitle}>
{series.title}
</div>
<div
className={classNames(
styles.statusContainer,
fullColorEvents && 'fullColor'
)}
>
{
isMissingAbsoluteNumber &&
<Icon
containerClassName={styles.statusIcon}
name={icons.WARNING}
title={translate('EpisodeMissingAbsoluteNumber')}
/>
}
{
anyDownloading &&
<Icon
containerClassName={styles.statusIcon}
name={icons.DOWNLOADING}
title={translate('AnEpisodeIsDownloading')}
/>
}
{
firstEpisode.episodeNumber === 1 && seasonNumber > 0 &&
<Icon
containerClassName={styles.statusIcon}
name={icons.PREMIERE}
kind={kinds.INFO}
title={seasonNumber === 1 ? translate('SeriesPremiere') : translate('SeasonPremiere')}
/>
}
{
showFinaleIcon &&
lastEpisode.finaleType ?
<Icon
containerClassName={styles.statusIcon}
name={lastEpisode.finaleType === 'series' ? icons.FINALE_SERIES : icons.FINALE_SEASON}
kind={lastEpisode.finaleType === 'series' ? kinds.DANGER : kinds.WARNING}
title={getFinaleTypeName(lastEpisode.finaleType)}
/> : null
}
</div>
</div>
<div className={styles.airingInfo}>
<div className={styles.airTime}>
{formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
</div>
{
showEpisodeInformation ?
<div className={styles.episodeInfo}>
{seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}-{padNumber(lastEpisode.episodeNumber, 2)}
{
series.seriesType === 'anime' &&
firstEpisode.absoluteEpisodeNumber &&
lastEpisode.absoluteEpisodeNumber &&
<span className={styles.absoluteEpisodeNumber}>
({firstEpisode.absoluteEpisodeNumber}-{lastEpisode.absoluteEpisodeNumber})
</span>
}
</div> :
<Link
className={styles.expandContainerInline}
component="div"
onPress={this.onExpandPress}
>
<Icon
name={icons.EXPAND}
/>
</Link>
}
</div>
{
showEpisodeInformation ?
<Link
className={styles.expandContainer}
component="div"
onPress={this.onExpandPress}
>
&nbsp;
<Icon
name={icons.EXPAND}
/>
&nbsp;
</Link> :
null
}
</div>
);
}
}
CalendarEventGroup.propTypes = {
// Most of these props come from the connector and are required, but TS is confused.
series: PropTypes.object,
events: PropTypes.arrayOf(PropTypes.object).isRequired,
isDownloading: PropTypes.bool,
showEpisodeInformation: PropTypes.bool,
showFinaleIcon: PropTypes.bool,
fullColorEvents: PropTypes.bool,
timeFormat: PropTypes.string,
colorImpairedMode: PropTypes.bool,
onEventModalOpenToggle: PropTypes.func.isRequired
};
export default CalendarEventGroup;

View File

@@ -1,253 +0,0 @@
import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import getFinaleTypeName from 'Episode/getFinaleTypeName';
import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { CalendarItem } from 'typings/Calendar';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
import CalendarEvent from './CalendarEvent';
import styles from './CalendarEventGroup.css';
function createIsDownloadingSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.queue.details,
(details) => {
return details.items.some((item) => {
return !!(item.episodeId && episodeIds.includes(item.episodeId));
});
}
);
}
interface CalendarEventGroupProps {
episodeIds: number[];
seriesId: number;
events: CalendarItem[];
onEventModalOpenToggle: (isOpen: boolean) => void;
}
function CalendarEventGroup({
episodeIds,
seriesId,
events,
onEventModalOpenToggle,
}: CalendarEventGroupProps) {
const isDownloading = useSelector(createIsDownloadingSelector(episodeIds));
const series = useSeries(seriesId)!;
const { timeFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector()
);
const { showEpisodeInformation, showFinaleIcon, fullColorEvents } =
useSelector((state: AppState) => state.calendar.options);
const [isExpanded, setIsExpanded] = useState(false);
const firstEpisode = events[0];
const lastEpisode = events[events.length - 1];
const airDateUtc = firstEpisode.airDateUtc;
const startTime = moment(airDateUtc);
const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
const seasonNumber = firstEpisode.seasonNumber;
const { allDownloaded, anyQueued, anyMonitored, allAbsoluteEpisodeNumbers } =
useMemo(() => {
let files = 0;
let queued = 0;
let monitored = 0;
let absoluteEpisodeNumbers = 0;
events.forEach((event) => {
if (event.episodeFileId) {
files++;
}
if (event.queued) {
queued++;
}
if (series.monitored && event.monitored) {
monitored++;
}
if (event.absoluteEpisodeNumber) {
absoluteEpisodeNumbers++;
}
});
return {
allDownloaded: files === events.length,
anyQueued: queued > 0,
anyMonitored: monitored > 0,
allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length,
};
}, [series, events]);
const anyDownloading = isDownloading || anyQueued;
const statusStyle = getStatusStyle(
allDownloaded,
anyDownloading,
startTime,
endTime,
anyMonitored
);
const isMissingAbsoluteNumber =
series.seriesType === 'anime' &&
seasonNumber > 0 &&
!allAbsoluteEpisodeNumbers;
const handleExpandPress = useCallback(() => {
setIsExpanded((state) => !state);
}, []);
if (isExpanded) {
return (
<div>
{events.map((event) => {
return (
<CalendarEvent
key={event.id}
episodeId={event.id}
{...event}
onEventModalOpenToggle={onEventModalOpenToggle}
/>
);
})}
<Link
className={styles.collapseContainer}
component="div"
onPress={handleExpandPress}
>
<Icon name={icons.COLLAPSE} />
</Link>
</div>
);
}
return (
<div
className={classNames(
styles.eventGroup,
styles[statusStyle],
enableColorImpairedMode && 'colorImpaired',
fullColorEvents && 'fullColor'
)}
>
<div className={styles.info}>
<div className={styles.seriesTitle}>{series.title}</div>
<div
className={classNames(
styles.statusContainer,
fullColorEvents && 'fullColor'
)}
>
{isMissingAbsoluteNumber ? (
<Icon
containerClassName={styles.statusIcon}
name={icons.WARNING}
title={translate('EpisodeMissingAbsoluteNumber')}
/>
) : null}
{anyDownloading ? (
<Icon
containerClassName={styles.statusIcon}
name={icons.DOWNLOADING}
title={translate('AnEpisodeIsDownloading')}
/>
) : null}
{firstEpisode.episodeNumber === 1 && seasonNumber > 0 ? (
<Icon
containerClassName={styles.statusIcon}
name={icons.PREMIERE}
kind={kinds.INFO}
title={
seasonNumber === 1
? translate('SeriesPremiere')
: translate('SeasonPremiere')
}
/>
) : null}
{showFinaleIcon && lastEpisode.finaleType ? (
<Icon
containerClassName={styles.statusIcon}
name={
lastEpisode.finaleType === 'series'
? icons.FINALE_SERIES
: icons.FINALE_SEASON
}
kind={
lastEpisode.finaleType === 'series'
? kinds.DANGER
: kinds.WARNING
}
title={getFinaleTypeName(lastEpisode.finaleType)}
/>
) : null}
</div>
</div>
<div className={styles.airingInfo}>
<div className={styles.airTime}>
{formatTime(airDateUtc, timeFormat)} -{' '}
{formatTime(endTime.toISOString(), timeFormat, {
includeMinuteZero: true,
})}
</div>
{showEpisodeInformation ? (
<div className={styles.episodeInfo}>
{seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}-
{padNumber(lastEpisode.episodeNumber, 2)}
{series.seriesType === 'anime' &&
firstEpisode.absoluteEpisodeNumber &&
lastEpisode.absoluteEpisodeNumber ? (
<span className={styles.absoluteEpisodeNumber}>
({firstEpisode.absoluteEpisodeNumber}-
{lastEpisode.absoluteEpisodeNumber})
</span>
) : null}
</div>
) : (
<Link
className={styles.expandContainerInline}
component="div"
onPress={handleExpandPress}
>
<Icon name={icons.EXPAND} />
</Link>
)}
</div>
{showEpisodeInformation ? (
<Link
className={styles.expandContainer}
component="div"
onPress={handleExpandPress}
>
&nbsp;
<Icon name={icons.EXPAND} />
&nbsp;
</Link>
) : null}
</div>
);
}
export default CalendarEventGroup;

View File

@@ -0,0 +1,37 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import CalendarEventGroup from './CalendarEventGroup';
function createIsDownloadingSelector() {
return createSelector(
(state, { episodeIds }) => episodeIds,
(state) => state.queue.details,
(episodeIds, details) => {
return details.items.some((item) => {
return !!(item.episodeId && episodeIds.includes(item.episodeId));
});
}
);
}
function createMapStateToProps() {
return createSelector(
(state) => state.calendar.options,
createSeriesSelector(),
createIsDownloadingSelector(),
createUISettingsSelector(),
(calendarOptions, series, isDownloading, uiSettings) => {
return {
series,
isDownloading,
...calendarOptions,
timeFormat: uiSettings.timeFormat,
colorImpairedMode: uiSettings.enableColorImpairedMode
};
}
);
}
export default connect(createMapStateToProps)(CalendarEventGroup);

View File

@@ -0,0 +1,56 @@
import PropTypes from 'prop-types';
import React from 'react';
import QueueDetails from 'Activity/Queue/QueueDetails';
import CircularProgressBar from 'Components/CircularProgressBar';
function CalendarEventQueueDetails(props) {
const {
title,
size,
sizeleft,
estimatedCompletionTime,
status,
trackedDownloadState,
trackedDownloadStatus,
statusMessages,
errorMessage
} = props;
const progress = size ? (100 - sizeleft / size * 100) : 0;
return (
<QueueDetails
title={title}
size={size}
sizeleft={sizeleft}
estimatedCompletionTime={estimatedCompletionTime}
status={status}
trackedDownloadState={trackedDownloadState}
trackedDownloadStatus={trackedDownloadStatus}
statusMessages={statusMessages}
errorMessage={errorMessage}
progressBar={
<CircularProgressBar
progress={progress}
size={20}
strokeWidth={2}
strokeColor={'#7a43b6'}
/>
}
/>
);
}
CalendarEventQueueDetails.propTypes = {
title: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
sizeleft: PropTypes.number.isRequired,
estimatedCompletionTime: PropTypes.string,
status: PropTypes.string.isRequired,
trackedDownloadState: PropTypes.string.isRequired,
trackedDownloadStatus: PropTypes.string.isRequired,
statusMessages: PropTypes.arrayOf(PropTypes.object),
errorMessage: PropTypes.string
};
export default CalendarEventQueueDetails;

View File

@@ -1,58 +0,0 @@
import React from 'react';
import QueueDetails from 'Activity/Queue/QueueDetails';
import CircularProgressBar from 'Components/CircularProgressBar';
import {
QueueTrackedDownloadState,
QueueTrackedDownloadStatus,
StatusMessage,
} from 'typings/Queue';
interface CalendarEventQueueDetailsProps {
title: string;
size: number;
sizeleft: number;
estimatedCompletionTime?: string;
status: string;
trackedDownloadState: QueueTrackedDownloadState;
trackedDownloadStatus: QueueTrackedDownloadStatus;
statusMessages?: StatusMessage[];
errorMessage?: string;
}
function CalendarEventQueueDetails({
title,
size,
sizeleft,
estimatedCompletionTime,
status,
trackedDownloadState,
trackedDownloadStatus,
statusMessages,
errorMessage,
}: CalendarEventQueueDetailsProps) {
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
return (
<QueueDetails
title={title}
size={size}
sizeleft={sizeleft}
estimatedCompletionTime={estimatedCompletionTime}
status={status}
trackedDownloadState={trackedDownloadState}
trackedDownloadStatus={trackedDownloadStatus}
statusMessages={statusMessages}
errorMessage={errorMessage}
progressBar={
<CircularProgressBar
progress={progress}
size={20}
strokeWidth={2}
strokeColor="#7a43b6"
/>
}
/>
);
}
export default CalendarEventQueueDetails;

View File

@@ -0,0 +1,268 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import * as calendarViews from 'Calendar/calendarViews';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import ViewMenuItem from 'Components/Menu/ViewMenuItem';
import { align, icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import CalendarHeaderViewButton from './CalendarHeaderViewButton';
import styles from './CalendarHeader.css';
function getTitle(time, start, end, view, longDateFormat) {
const timeMoment = moment(time);
const startMoment = moment(start);
const endMoment = moment(end);
if (view === 'day') {
return timeMoment.format(longDateFormat);
} else if (view === 'month') {
return timeMoment.format('MMMM YYYY');
} else if (view === 'agenda') {
return translate('Agenda');
}
let startFormat = 'MMM D YYYY';
let endFormat = 'MMM D YYYY';
if (startMoment.isSame(endMoment, 'month')) {
startFormat = 'MMM D';
endFormat = 'D YYYY';
} else if (startMoment.isSame(endMoment, 'year')) {
startFormat = 'MMM D';
endFormat = 'MMM D YYYY';
}
return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(endFormat)}`;
}
// TODO Convert to a stateful Component so we can track view internally when changed
class CalendarHeader extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
view: props.view
};
}
componentDidUpdate(prevProps) {
const view = this.props.view;
if (prevProps.view !== view) {
this.setState({ view });
}
}
//
// Listeners
onViewChange = (view) => {
this.setState({ view }, () => {
this.props.onViewChange(view);
});
};
//
// Render
render() {
const {
isFetching,
time,
start,
end,
longDateFormat,
isSmallScreen,
collapseViewButtons,
onTodayPress,
onPreviousPress,
onNextPress
} = this.props;
const view = this.state.view;
const title = getTitle(time, start, end, view, longDateFormat);
return (
<div>
{
isSmallScreen &&
<div className={styles.titleMobile}>
{title}
</div>
}
<div className={styles.header}>
<div className={styles.navigationButtons}>
<Button
buttonGroupPosition={align.LEFT}
isDisabled={view === calendarViews.AGENDA}
onPress={onPreviousPress}
>
<Icon name={icons.PAGE_PREVIOUS} />
</Button>
<Button
buttonGroupPosition={align.RIGHT}
isDisabled={view === calendarViews.AGENDA}
onPress={onNextPress}
>
<Icon name={icons.PAGE_NEXT} />
</Button>
<Button
className={styles.todayButton}
isDisabled={view === calendarViews.AGENDA}
onPress={onTodayPress}
>
{translate('Today')}
</Button>
</div>
{
!isSmallScreen &&
<div className={styles.titleDesktop}>
{title}
</div>
}
<div className={styles.viewButtonsContainer}>
{
isFetching &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
{
collapseViewButtons ?
<Menu
className={styles.viewMenu}
alignMenu={align.RIGHT}
>
<MenuButton>
<Icon
name={icons.VIEW}
size={22}
/>
</MenuButton>
<MenuContent>
{
isSmallScreen ?
null :
<ViewMenuItem
name={calendarViews.MONTH}
selectedView={view}
onPress={this.onViewChange}
>
{translate('Month')}
</ViewMenuItem>
}
<ViewMenuItem
name={calendarViews.WEEK}
selectedView={view}
onPress={this.onViewChange}
>
{translate('Week')}
</ViewMenuItem>
<ViewMenuItem
name={calendarViews.FORECAST}
selectedView={view}
onPress={this.onViewChange}
>
{translate('Forecast')}
</ViewMenuItem>
<ViewMenuItem
name={calendarViews.DAY}
selectedView={view}
onPress={this.onViewChange}
>
{translate('Day')}
</ViewMenuItem>
<ViewMenuItem
name={calendarViews.AGENDA}
selectedView={view}
onPress={this.onViewChange}
>
{translate('Agenda')}
</ViewMenuItem>
</MenuContent>
</Menu> :
<div className={styles.viewButtons}>
<CalendarHeaderViewButton
view={calendarViews.MONTH}
selectedView={view}
buttonGroupPosition={align.LEFT}
onPress={this.onViewChange}
/>
<CalendarHeaderViewButton
view={calendarViews.WEEK}
selectedView={view}
buttonGroupPosition={align.CENTER}
onPress={this.onViewChange}
/>
<CalendarHeaderViewButton
view={calendarViews.FORECAST}
selectedView={view}
buttonGroupPosition={align.CENTER}
onPress={this.onViewChange}
/>
<CalendarHeaderViewButton
view={calendarViews.DAY}
selectedView={view}
buttonGroupPosition={align.CENTER}
onPress={this.onViewChange}
/>
<CalendarHeaderViewButton
view={calendarViews.AGENDA}
selectedView={view}
buttonGroupPosition={align.RIGHT}
onPress={this.onViewChange}
/>
</div>
}
</div>
</div>
</div>
);
}
}
CalendarHeader.propTypes = {
isFetching: PropTypes.bool.isRequired,
time: PropTypes.string.isRequired,
start: PropTypes.string.isRequired,
end: PropTypes.string.isRequired,
view: PropTypes.oneOf(calendarViews.all).isRequired,
isSmallScreen: PropTypes.bool.isRequired,
collapseViewButtons: PropTypes.bool.isRequired,
longDateFormat: PropTypes.string.isRequired,
onViewChange: PropTypes.func.isRequired,
onTodayPress: PropTypes.func.isRequired,
onPreviousPress: PropTypes.func.isRequired,
onNextPress: PropTypes.func.isRequired
};
export default CalendarHeader;

View File

@@ -1,221 +0,0 @@
import moment from 'moment';
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { CalendarView } from 'Calendar/calendarViews';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import ViewMenuItem from 'Components/Menu/ViewMenuItem';
import { align, icons } from 'Helpers/Props';
import {
gotoCalendarNextRange,
gotoCalendarPreviousRange,
gotoCalendarToday,
setCalendarView,
} from 'Store/Actions/calendarActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import translate from 'Utilities/String/translate';
import CalendarHeaderViewButton from './CalendarHeaderViewButton';
import styles from './CalendarHeader.css';
function CalendarHeader() {
const dispatch = useDispatch();
const { isFetching, view, time, start, end } = useSelector(
(state: AppState) => state.calendar
);
const { isSmallScreen, isLargeScreen } = useSelector(
createDimensionsSelector()
);
const { longDateFormat } = useSelector(createUISettingsSelector());
const handleViewChange = useCallback(
(newView: CalendarView) => {
dispatch(setCalendarView({ view: newView }));
},
[dispatch]
);
const handleTodayPress = useCallback(() => {
dispatch(gotoCalendarToday());
}, [dispatch]);
const handlePreviousPress = useCallback(() => {
dispatch(gotoCalendarPreviousRange());
}, [dispatch]);
const handleNextPress = useCallback(() => {
dispatch(gotoCalendarNextRange());
}, [dispatch]);
const title = useMemo(() => {
const timeMoment = moment(time);
const startMoment = moment(start);
const endMoment = moment(end);
if (view === 'day') {
return timeMoment.format(longDateFormat);
} else if (view === 'month') {
return timeMoment.format('MMMM YYYY');
} else if (view === 'agenda') {
return translate('Agenda');
}
let startFormat = 'MMM D YYYY';
let endFormat = 'MMM D YYYY';
if (startMoment.isSame(endMoment, 'month')) {
startFormat = 'MMM D';
endFormat = 'D YYYY';
} else if (startMoment.isSame(endMoment, 'year')) {
startFormat = 'MMM D';
endFormat = 'MMM D YYYY';
}
return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(
endFormat
)}`;
}, [time, start, end, view, longDateFormat]);
return (
<div>
{isSmallScreen ? <div className={styles.titleMobile}>{title}</div> : null}
<div className={styles.header}>
<div className={styles.navigationButtons}>
<Button
buttonGroupPosition="left"
isDisabled={view === 'agenda'}
onPress={handlePreviousPress}
>
<Icon name={icons.PAGE_PREVIOUS} />
</Button>
<Button
buttonGroupPosition="right"
isDisabled={view === 'agenda'}
onPress={handleNextPress}
>
<Icon name={icons.PAGE_NEXT} />
</Button>
<Button
className={styles.todayButton}
isDisabled={view === 'agenda'}
onPress={handleTodayPress}
>
{translate('Today')}
</Button>
</div>
{isSmallScreen ? null : (
<div className={styles.titleDesktop}>{title}</div>
)}
<div className={styles.viewButtonsContainer}>
{isFetching ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
{isLargeScreen ? (
<Menu className={styles.viewMenu} alignMenu={align.RIGHT}>
<MenuButton>
<Icon name={icons.VIEW} size={22} />
</MenuButton>
<MenuContent>
{isSmallScreen ? null : (
<ViewMenuItem
name="month"
selectedView={view}
onPress={handleViewChange}
>
{translate('Month')}
</ViewMenuItem>
)}
<ViewMenuItem
name="week"
selectedView={view}
onPress={handleViewChange}
>
{translate('Week')}
</ViewMenuItem>
<ViewMenuItem
name="forecast"
selectedView={view}
onPress={handleViewChange}
>
{translate('Forecast')}
</ViewMenuItem>
<ViewMenuItem
name="day"
selectedView={view}
onPress={handleViewChange}
>
{translate('Day')}
</ViewMenuItem>
<ViewMenuItem
name="agenda"
selectedView={view}
onPress={handleViewChange}
>
{translate('Agenda')}
</ViewMenuItem>
</MenuContent>
</Menu>
) : (
<>
<CalendarHeaderViewButton
view="month"
selectedView={view}
buttonGroupPosition="left"
onPress={handleViewChange}
/>
<CalendarHeaderViewButton
view="week"
selectedView={view}
buttonGroupPosition="center"
onPress={handleViewChange}
/>
<CalendarHeaderViewButton
view="forecast"
selectedView={view}
buttonGroupPosition="center"
onPress={handleViewChange}
/>
<CalendarHeaderViewButton
view="day"
selectedView={view}
buttonGroupPosition="center"
onPress={handleViewChange}
/>
<CalendarHeaderViewButton
view="agenda"
selectedView={view}
buttonGroupPosition="right"
onPress={handleViewChange}
/>
</>
)}
</div>
</div>
</div>
);
}
export default CalendarHeader;

View File

@@ -0,0 +1,85 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { gotoCalendarNextRange, gotoCalendarPreviousRange, gotoCalendarToday, setCalendarView } from 'Store/Actions/calendarActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import CalendarHeader from './CalendarHeader';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar,
createDimensionsSelector(),
createUISettingsSelector(),
(calendar, dimensions, uiSettings) => {
const result = _.pick(calendar, [
'isFetching',
'view',
'time',
'start',
'end'
]);
result.isSmallScreen = dimensions.isSmallScreen;
result.collapseViewButtons = dimensions.isLargeScreen;
result.longDateFormat = uiSettings.longDateFormat;
return result;
}
);
}
const mapDispatchToProps = {
setCalendarView,
gotoCalendarToday,
gotoCalendarPreviousRange,
gotoCalendarNextRange
};
class CalendarHeaderConnector extends Component {
//
// Listeners
onViewChange = (view) => {
this.props.setCalendarView({ view });
};
onTodayPress = () => {
this.props.gotoCalendarToday();
};
onPreviousPress = () => {
this.props.gotoCalendarPreviousRange();
};
onNextPress = () => {
this.props.gotoCalendarNextRange();
};
//
// Render
render() {
return (
<CalendarHeader
{...this.props}
onViewChange={this.onViewChange}
onTodayPress={this.onTodayPress}
onPreviousPress={this.onPreviousPress}
onNextPress={this.onNextPress}
/>
);
}
}
CalendarHeaderConnector.propTypes = {
setCalendarView: PropTypes.func.isRequired,
gotoCalendarToday: PropTypes.func.isRequired,
gotoCalendarPreviousRange: PropTypes.func.isRequired,
gotoCalendarNextRange: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarHeaderConnector);

View File

@@ -0,0 +1,45 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import * as calendarViews from 'Calendar/calendarViews';
import Button from 'Components/Link/Button';
import titleCase from 'Utilities/String/titleCase';
// import styles from './CalendarHeaderViewButton.css';
class CalendarHeaderViewButton extends Component {
//
// Listeners
onPress = () => {
this.props.onPress(this.props.view);
};
//
// Render
render() {
const {
view,
selectedView,
...otherProps
} = this.props;
return (
<Button
isDisabled={selectedView === view}
{...otherProps}
onPress={this.onPress}
>
{titleCase(view)}
</Button>
);
}
}
CalendarHeaderViewButton.propTypes = {
view: PropTypes.oneOf(calendarViews.all).isRequired,
selectedView: PropTypes.oneOf(calendarViews.all).isRequired,
onPress: PropTypes.func.isRequired
};
export default CalendarHeaderViewButton;

View File

@@ -1,34 +0,0 @@
import React, { useCallback } from 'react';
import { CalendarView } from 'Calendar/calendarViews';
import Button, { ButtonProps } from 'Components/Link/Button';
import titleCase from 'Utilities/String/titleCase';
interface CalendarHeaderViewButtonProps
extends Omit<ButtonProps, 'children' | 'onPress'> {
view: CalendarView;
selectedView: CalendarView;
onPress: (view: CalendarView) => void;
}
function CalendarHeaderViewButton({
view,
selectedView,
onPress,
...otherProps
}: CalendarHeaderViewButtonProps) {
const handlePress = useCallback(() => {
onPress(view);
}, [view, onPress]);
return (
<Button
isDisabled={selectedView === view}
{...otherProps}
onPress={handlePress}
>
{titleCase(view)}
</Button>
);
}
export default CalendarHeaderViewButton;

View File

@@ -1,22 +1,20 @@
import PropTypes from 'prop-types';
import React from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { icons, kinds } from 'Helpers/Props';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import translate from 'Utilities/String/translate';
import LegendIconItem from './LegendIconItem';
import LegendItem from './LegendItem';
import styles from './Legend.css';
function Legend() {
const view = useSelector((state: AppState) => state.calendar.view);
function Legend(props) {
const {
view,
showFinaleIcon,
showSpecialIcon,
showCutoffUnmetIcon,
fullColorEvents,
} = useSelector((state: AppState) => state.calendar.options);
const { enableColorImpairedMode } = useSelector(createUISettingsSelector());
colorImpairedMode
} = props;
const iconsToShow = [];
const isAgendaView = view === 'agenda';
@@ -58,7 +56,7 @@ function Legend() {
if (showCutoffUnmetIcon) {
iconsToShow.push(
<LegendIconItem
name={translate('CutoffNotMet')}
name={translate('Cutoff Not Met')}
icon={icons.EPISODE_FILE}
kind={kinds.WARNING}
fullColorEvents={fullColorEvents}
@@ -75,7 +73,7 @@ function Legend() {
tooltip={translate('CalendarLegendEpisodeUnairedTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={enableColorImpairedMode}
colorImpairedMode={colorImpairedMode}
/>
<LegendItem
@@ -83,7 +81,7 @@ function Legend() {
tooltip={translate('CalendarLegendEpisodeUnmonitoredTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={enableColorImpairedMode}
colorImpairedMode={colorImpairedMode}
/>
</div>
@@ -94,7 +92,7 @@ function Legend() {
tooltip={translate('CalendarLegendEpisodeOnAirTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={enableColorImpairedMode}
colorImpairedMode={colorImpairedMode}
/>
<LegendItem
@@ -102,7 +100,7 @@ function Legend() {
tooltip={translate('CalendarLegendEpisodeMissingTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={enableColorImpairedMode}
colorImpairedMode={colorImpairedMode}
/>
</div>
@@ -112,7 +110,7 @@ function Legend() {
tooltip={translate('CalendarLegendEpisodeDownloadingTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={enableColorImpairedMode}
colorImpairedMode={colorImpairedMode}
/>
<LegendItem
@@ -120,7 +118,7 @@ function Legend() {
tooltip={translate('CalendarLegendEpisodeDownloadedTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={enableColorImpairedMode}
colorImpairedMode={colorImpairedMode}
/>
</div>
@@ -136,15 +134,30 @@ function Legend() {
{iconsToShow[0]}
</div>
{iconsToShow.length > 1 ? (
<div>
{iconsToShow[1]}
{iconsToShow[2]}
</div>
) : null}
{iconsToShow.length > 3 ? <div>{iconsToShow[3]}</div> : null}
{
iconsToShow.length > 1 &&
<div>
{iconsToShow[1]}
{iconsToShow[2]}
</div>
}
{
iconsToShow.length > 3 &&
<div>
{iconsToShow[3]}
</div>
}
</div>
);
}
Legend.propTypes = {
view: PropTypes.string.isRequired,
showFinaleIcon: PropTypes.bool.isRequired,
showSpecialIcon: PropTypes.bool.isRequired,
showCutoffUnmetIcon: PropTypes.bool.isRequired,
fullColorEvents: PropTypes.bool.isRequired,
colorImpairedMode: PropTypes.bool.isRequired
};
export default Legend;

View File

@@ -0,0 +1,21 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import Legend from './Legend';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar.options,
(state) => state.calendar.view,
createUISettingsSelector(),
(calendarOptions, view, uiSettings) => {
return {
...calendarOptions,
view,
colorImpairedMode: uiSettings.enableColorImpairedMode
};
}
);
}
export default connect(createMapStateToProps)(Legend);

View File

@@ -0,0 +1,43 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import styles from './LegendIconItem.css';
function LegendIconItem(props) {
const {
name,
fullColorEvents,
icon,
kind,
tooltip
} = props;
return (
<div
className={styles.legendIconItem}
title={tooltip}
>
<Icon
className={classNames(
styles.icon,
fullColorEvents && 'fullColorEvents'
)}
name={icon}
kind={kind}
/>
{name}
</div>
);
}
LegendIconItem.propTypes = {
name: PropTypes.string.isRequired,
fullColorEvents: PropTypes.bool.isRequired,
icon: PropTypes.object.isRequired,
kind: PropTypes.string.isRequired,
tooltip: PropTypes.string.isRequired
};
export default LegendIconItem;

View File

@@ -1,33 +0,0 @@
import { FontAwesomeIconProps } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import React from 'react';
import Icon, { IconProps } from 'Components/Icon';
import styles from './LegendIconItem.css';
interface LegendIconItemProps extends Pick<IconProps, 'kind'> {
name: string;
fullColorEvents: boolean;
icon: FontAwesomeIconProps['icon'];
tooltip: string;
}
function LegendIconItem(props: LegendIconItemProps) {
const { name, fullColorEvents, icon, kind, tooltip } = props;
return (
<div className={styles.legendIconItem} title={tooltip}>
<Icon
className={classNames(
styles.icon,
fullColorEvents && 'fullColorEvents'
)}
name={icon}
kind={kind}
/>
{name}
</div>
);
}
export default LegendIconItem;

View File

@@ -1,26 +1,17 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { CalendarStatus } from 'typings/Calendar';
import titleCase from 'Utilities/String/titleCase';
import styles from './LegendItem.css';
interface LegendItemProps {
name?: string;
status: CalendarStatus;
tooltip: string;
isAgendaView: boolean;
fullColorEvents: boolean;
colorImpairedMode: boolean;
}
function LegendItem(props: LegendItemProps) {
function LegendItem(props) {
const {
name,
status,
tooltip,
isAgendaView,
fullColorEvents,
colorImpairedMode,
colorImpairedMode
} = props;
return (
@@ -38,4 +29,13 @@ function LegendItem(props: LegendItemProps) {
);
}
LegendItem.propTypes = {
name: PropTypes.string,
status: PropTypes.string.isRequired,
tooltip: PropTypes.string.isRequired,
isAgendaView: PropTypes.bool.isRequired,
fullColorEvents: PropTypes.bool.isRequired,
colorImpairedMode: PropTypes.bool.isRequired
};
export default LegendItem;

View File

@@ -0,0 +1,29 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector';
function CalendarOptionsModal(props) {
const {
isOpen,
onModalClose
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<CalendarOptionsModalContentConnector
onModalClose={onModalClose}
/>
</Modal>
);
}
CalendarOptionsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default CalendarOptionsModal;

View File

@@ -1,21 +0,0 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import CalendarOptionsModalContent from './CalendarOptionsModalContent';
interface CalendarOptionsModalProps {
isOpen: boolean;
onModalClose: () => void;
}
function CalendarOptionsModal({
isOpen,
onModalClose,
}: CalendarOptionsModalProps) {
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<CalendarOptionsModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default CalendarOptionsModal;

View File

@@ -0,0 +1,276 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import { firstDayOfWeekOptions, timeFormatOptions, weekColumnOptions } from 'Settings/UI/UISettings';
import translate from 'Utilities/String/translate';
class CalendarOptionsModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode,
fullColorEvents
} = props;
this.state = {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode,
fullColorEvents
};
}
componentDidUpdate(prevProps) {
const {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode
} = this.props;
if (
prevProps.firstDayOfWeek !== firstDayOfWeek ||
prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader ||
prevProps.timeFormat !== timeFormat ||
prevProps.enableColorImpairedMode !== enableColorImpairedMode
) {
this.setState({
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode
});
}
}
//
// Listeners
onOptionInputChange = ({ name, value }) => {
const {
dispatchSetCalendarOption
} = this.props;
dispatchSetCalendarOption({ [name]: value });
};
onGlobalInputChange = ({ name, value }) => {
const {
dispatchSaveUISettings
} = this.props;
const setting = { [name]: value };
this.setState(setting, () => {
dispatchSaveUISettings(setting);
});
};
onLinkFocus = (event) => {
event.target.select();
};
//
// Render
render() {
const {
collapseMultipleEpisodes,
showEpisodeInformation,
showFinaleIcon,
showSpecialIcon,
showCutoffUnmetIcon,
fullColorEvents,
onModalClose
} = this.props;
const {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('CalendarOptions')}
</ModalHeader>
<ModalBody>
<FieldSet legend={translate('Local')}>
<Form>
<FormGroup>
<FormLabel>{translate('CollapseMultipleEpisodes')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="collapseMultipleEpisodes"
value={collapseMultipleEpisodes}
helpText={translate('CollapseMultipleEpisodesHelpText')}
onChange={this.onOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowEpisodeInformation')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showEpisodeInformation"
value={showEpisodeInformation}
helpText={translate('ShowEpisodeInformationHelpText')}
onChange={this.onOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IconForFinales')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showFinaleIcon"
value={showFinaleIcon}
helpText={translate('IconForFinalesHelpText')}
onChange={this.onOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IconForSpecials')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSpecialIcon"
value={showSpecialIcon}
helpText={translate('IconForSpecialsHelpText')}
onChange={this.onOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IconForCutoffUnmet')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showCutoffUnmetIcon"
value={showCutoffUnmetIcon}
helpText={translate('IconForCutoffUnmetHelpText')}
onChange={this.onOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('FullColorEvents')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="fullColorEvents"
value={fullColorEvents}
helpText={translate('FullColorEventsHelpText')}
onChange={this.onOptionInputChange}
/>
</FormGroup>
</Form>
</FieldSet>
<FieldSet legend={translate('Global')}>
<Form>
<FormGroup>
<FormLabel>{translate('FirstDayOfWeek')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="firstDayOfWeek"
values={firstDayOfWeekOptions}
value={firstDayOfWeek}
onChange={this.onGlobalInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('WeekColumnHeader')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="calendarWeekColumnHeader"
values={weekColumnOptions}
value={calendarWeekColumnHeader}
onChange={this.onGlobalInputChange}
helpText={translate('WeekColumnHeaderHelpText')}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('TimeFormat')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="timeFormat"
values={timeFormatOptions}
value={timeFormat}
onChange={this.onGlobalInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('EnableColorImpairedMode')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="enableColorImpairedMode"
value={enableColorImpairedMode}
helpText={translate('EnableColorImpairedModeHelpText')}
onChange={this.onGlobalInputChange}
/>
</FormGroup>
</Form>
</FieldSet>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
CalendarOptionsModalContent.propTypes = {
collapseMultipleEpisodes: PropTypes.bool.isRequired,
showEpisodeInformation: PropTypes.bool.isRequired,
showFinaleIcon: PropTypes.bool.isRequired,
showSpecialIcon: PropTypes.bool.isRequired,
showCutoffUnmetIcon: PropTypes.bool.isRequired,
firstDayOfWeek: PropTypes.number.isRequired,
calendarWeekColumnHeader: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
enableColorImpairedMode: PropTypes.bool.isRequired,
fullColorEvents: PropTypes.bool.isRequired,
dispatchSetCalendarOption: PropTypes.func.isRequired,
dispatchSaveUISettings: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default CalendarOptionsModalContent;

View File

@@ -1,228 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import {
firstDayOfWeekOptions,
timeFormatOptions,
weekColumnOptions,
} from 'Settings/UI/UISettings';
import { setCalendarOption } from 'Store/Actions/calendarActions';
import { saveUISettings } from 'Store/Actions/settingsActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { InputChanged } from 'typings/inputs';
import UiSettings from 'typings/Settings/UiSettings';
import translate from 'Utilities/String/translate';
interface CalendarOptionsModalContentProps {
onModalClose: () => void;
}
function CalendarOptionsModalContent({
onModalClose,
}: CalendarOptionsModalContentProps) {
const dispatch = useDispatch();
const {
collapseMultipleEpisodes,
showEpisodeInformation,
showFinaleIcon,
showSpecialIcon,
showCutoffUnmetIcon,
fullColorEvents,
} = useSelector((state: AppState) => state.calendar.options);
const uiSettings = useSelector(createUISettingsSelector());
const [state, setState] = useState<Partial<UiSettings>>({
firstDayOfWeek: uiSettings.firstDayOfWeek,
calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader,
timeFormat: uiSettings.timeFormat,
enableColorImpairedMode: uiSettings.enableColorImpairedMode,
});
const {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode,
} = state;
const handleOptionInputChange = useCallback(
({ name, value }: InputChanged) => {
dispatch(setCalendarOption({ [name]: value }));
},
[dispatch]
);
const handleGlobalInputChange = useCallback(
({ name, value }: InputChanged) => {
setState((prevState) => ({ ...prevState, [name]: value }));
dispatch(saveUISettings({ [name]: value }));
},
[dispatch]
);
useEffect(() => {
setState({
firstDayOfWeek: uiSettings.firstDayOfWeek,
calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader,
timeFormat: uiSettings.timeFormat,
enableColorImpairedMode: uiSettings.enableColorImpairedMode,
});
}, [uiSettings]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('CalendarOptions')}</ModalHeader>
<ModalBody>
<FieldSet legend={translate('Local')}>
<Form>
<FormGroup>
<FormLabel>{translate('CollapseMultipleEpisodes')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="collapseMultipleEpisodes"
value={collapseMultipleEpisodes}
helpText={translate('CollapseMultipleEpisodesHelpText')}
onChange={handleOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowEpisodeInformation')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showEpisodeInformation"
value={showEpisodeInformation}
helpText={translate('ShowEpisodeInformationHelpText')}
onChange={handleOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IconForFinales')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showFinaleIcon"
value={showFinaleIcon}
helpText={translate('IconForFinalesHelpText')}
onChange={handleOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IconForSpecials')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSpecialIcon"
value={showSpecialIcon}
helpText={translate('IconForSpecialsHelpText')}
onChange={handleOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IconForCutoffUnmet')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showCutoffUnmetIcon"
value={showCutoffUnmetIcon}
helpText={translate('IconForCutoffUnmetHelpText')}
onChange={handleOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('FullColorEvents')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="fullColorEvents"
value={fullColorEvents}
helpText={translate('FullColorEventsHelpText')}
onChange={handleOptionInputChange}
/>
</FormGroup>
</Form>
</FieldSet>
<FieldSet legend={translate('Global')}>
<Form>
<FormGroup>
<FormLabel>{translate('FirstDayOfWeek')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="firstDayOfWeek"
values={firstDayOfWeekOptions}
value={firstDayOfWeek}
onChange={handleGlobalInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('WeekColumnHeader')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="calendarWeekColumnHeader"
values={weekColumnOptions}
value={calendarWeekColumnHeader}
helpText={translate('WeekColumnHeaderHelpText')}
onChange={handleGlobalInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('TimeFormat')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="timeFormat"
values={timeFormatOptions}
value={timeFormat}
onChange={handleGlobalInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('EnableColorImpairedMode')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="enableColorImpairedMode"
value={enableColorImpairedMode}
helpText={translate('EnableColorImpairedModeHelpText')}
onChange={handleGlobalInputChange}
/>
</FormGroup>
</Form>
</FieldSet>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default CalendarOptionsModalContent;

View File

@@ -0,0 +1,25 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setCalendarOption } from 'Store/Actions/calendarActions';
import { saveUISettings } from 'Store/Actions/settingsActions';
import CalendarOptionsModalContent from './CalendarOptionsModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar.options,
(state) => state.settings.ui.item,
(options, uiSettings) => {
return {
...options,
...uiSettings
};
}
);
}
const mapDispatchToProps = {
dispatchSetCalendarOption: setCalendarOption,
dispatchSaveUISettings: saveUISettings
};
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent);

View File

@@ -5,5 +5,3 @@ export const FORECAST = 'forecast';
export const AGENDA = 'agenda';
export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA];
export type CalendarView = 'agenda' | 'day' | 'forecast' | 'month' | 'week';

View File

@@ -1,13 +1,7 @@
/* eslint max-params: 0 */
import moment from 'moment';
import { CalendarStatus } from 'typings/Calendar';
function getStatusStyle(
hasFile: boolean,
downloading: boolean,
startTime: moment.Moment,
endTime: moment.Moment,
isMonitored: boolean
): CalendarStatus {
function getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored) {
const currentTime = moment();
if (hasFile) {

View File

@@ -0,0 +1,29 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import CalendarLinkModalContentConnector from './CalendarLinkModalContentConnector';
function CalendarLinkModal(props) {
const {
isOpen,
onModalClose
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<CalendarLinkModalContentConnector
onModalClose={onModalClose}
/>
</Modal>
);
}
CalendarLinkModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default CalendarLinkModal;

View File

@@ -1,20 +0,0 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import CalendarLinkModalContent from './CalendarLinkModalContent';
interface CalendarLinkModalProps {
isOpen: boolean;
onModalClose: () => void;
}
function CalendarLinkModal(props: CalendarLinkModalProps) {
const { isOpen, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<CalendarLinkModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default CalendarLinkModal;

View File

@@ -0,0 +1,222 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputButton from 'Components/Form/FormInputButton';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import ClipboardButton from 'Components/Link/ClipboardButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
function getUrls(state) {
const {
unmonitored,
premieresOnly,
asAllDay,
tags
} = state;
let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`;
if (unmonitored) {
icalUrl += 'unmonitored=true&';
}
if (premieresOnly) {
icalUrl += 'premieresOnly=true&';
}
if (asAllDay) {
icalUrl += 'asAllDay=true&';
}
if (tags.length) {
icalUrl += `tags=${tags.toString()}&`;
}
icalUrl += `apikey=${encodeURIComponent(window.Sonarr.apiKey)}`;
const iCalHttpUrl = `${window.location.protocol}//${icalUrl}`;
const iCalWebCalUrl = `webcal://${icalUrl}`;
return {
iCalHttpUrl,
iCalWebCalUrl
};
}
class CalendarLinkModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const defaultState = {
unmonitored: false,
premieresOnly: false,
asAllDay: false,
tags: []
};
const urls = getUrls(defaultState);
this.state = {
...defaultState,
...urls
};
}
//
// Listeners
onInputChange = ({ name, value }) => {
const state = {
...this.state,
[name]: value
};
const urls = getUrls(state);
this.setState({
[name]: value,
...urls
});
};
onLinkFocus = (event) => {
event.target.select();
};
//
// Render
render() {
const {
onModalClose
} = this.props;
const {
unmonitored,
premieresOnly,
asAllDay,
tags,
iCalHttpUrl,
iCalWebCalUrl
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('CalendarFeed')}
</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('IncludeUnmonitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="unmonitored"
value={unmonitored}
helpText={translate('ICalIncludeUnmonitoredEpisodesHelpText')}
onChange={this.onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('SeasonPremieresOnly')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="premieresOnly"
value={premieresOnly}
helpText={translate('ICalSeasonPremieresOnlyHelpText')}
onChange={this.onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ICalShowAsAllDayEvents')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="asAllDay"
value={asAllDay}
helpText={translate('ICalShowAsAllDayEventsHelpText')}
onChange={this.onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
value={tags}
helpText={translate('ICalTagsSeriesHelpText')}
onChange={this.onInputChange}
/>
</FormGroup>
<FormGroup
size={sizes.LARGE}
>
<FormLabel>{translate('ICalFeed')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="iCalHttpUrl"
value={iCalHttpUrl}
readOnly={true}
helpText={translate('ICalFeedHelpText')}
buttons={[
<ClipboardButton
key="copy"
value={iCalHttpUrl}
kind={kinds.DEFAULT}
/>,
<FormInputButton
key="webcal"
kind={kinds.DEFAULT}
to={iCalWebCalUrl}
target="_blank"
noRouter={true}
>
<Icon name={icons.CALENDAR_O} />
</FormInputButton>
]}
onChange={this.onInputChange}
onFocus={this.onLinkFocus}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
CalendarLinkModalContent.propTypes = {
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
onModalClose: PropTypes.func.isRequired
};
export default CalendarLinkModalContent;

View File

@@ -1,166 +0,0 @@
import React, { FocusEvent, useCallback, useMemo, useState } from 'react';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputButton from 'Components/Form/FormInputButton';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import ClipboardButton from 'Components/Link/ClipboardButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
interface CalendarLinkModalContentProps {
onModalClose: () => void;
}
function CalendarLinkModalContent({
onModalClose,
}: CalendarLinkModalContentProps) {
const [state, setState] = useState({
unmonitored: false,
premieresOnly: false,
asAllDay: false,
tags: [],
});
const { unmonitored, premieresOnly, asAllDay, tags } = state;
const handleInputChange = useCallback(({ name, value }: InputChanged) => {
setState((prevState) => ({ ...prevState, [name]: value }));
}, []);
const handleLinkFocus = useCallback(
(event: FocusEvent<HTMLInputElement, Element>) => {
event.target.select();
},
[]
);
const { iCalHttpUrl, iCalWebCalUrl } = useMemo(() => {
let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`;
if (unmonitored) {
icalUrl += 'unmonitored=true&';
}
if (premieresOnly) {
icalUrl += 'premieresOnly=true&';
}
if (asAllDay) {
icalUrl += 'asAllDay=true&';
}
if (tags.length) {
icalUrl += `tags=${tags.toString()}&`;
}
icalUrl += `apikey=${encodeURIComponent(window.Sonarr.apiKey)}`;
return {
iCalHttpUrl: `${window.location.protocol}//${icalUrl}`,
iCalWebCalUrl: `webcal://${icalUrl}`,
};
}, [unmonitored, premieresOnly, asAllDay, tags]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('CalendarFeed')}</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('IncludeUnmonitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="unmonitored"
value={unmonitored}
helpText={translate('ICalIncludeUnmonitoredEpisodesHelpText')}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('SeasonPremieresOnly')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="premieresOnly"
value={premieresOnly}
helpText={translate('ICalSeasonPremieresOnlyHelpText')}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ICalShowAsAllDayEvents')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="asAllDay"
value={asAllDay}
helpText={translate('ICalShowAsAllDayEventsHelpText')}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.SERIES_TAG}
name="tags"
value={tags}
helpText={translate('ICalTagsSeriesHelpText')}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup size={sizes.LARGE}>
<FormLabel>{translate('ICalFeed')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="iCalHttpUrl"
value={iCalHttpUrl}
readOnly={true}
helpText={translate('ICalFeedHelpText')}
buttons={[
<ClipboardButton
key="copy"
value={iCalHttpUrl}
kind={kinds.DEFAULT}
/>,
<FormInputButton
key="webcal"
kind={kinds.DEFAULT}
to={iCalWebCalUrl}
target="_blank"
noRouter={true}
>
<Icon name={icons.CALENDAR_O} />
</FormInputButton>,
]}
onChange={handleInputChange}
onFocus={handleLinkFocus}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default CalendarLinkModalContent;

View File

@@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import CalendarLinkModalContent from './CalendarLinkModalContent';
function createMapStateToProps() {
return createSelector(
createTagsSelector(),
(tagList) => {
return {
tagList
};
}
);
}
export default connect(createMapStateToProps)(CalendarLinkModalContent);

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import { PathInputInternal } from 'Components/Form/PathInput';
import PathInput from 'Components/Form/PathInput';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
@@ -151,7 +151,7 @@ function FileBrowserModalContent(props: FileBrowserModalContentProps) {
</Alert>
) : null}
<PathInputInternal
<PathInput
className={styles.pathInput}
placeholder={translate('FileBrowserPlaceholderText')}
hasFileBrowser={false}

View File

@@ -13,7 +13,6 @@ import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue';
import QueueStatusFilterBuilderRowValue from './QueueStatusFilterBuilderRowValue';
import SeasonsMonitoredStatusFilterBuilderRowValue from './SeasonsMonitoredStatusFilterBuilderRowValue';
import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue';
import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue';
@@ -81,9 +80,6 @@ function getRowValueConnector(selectedFilterBuilderProp) {
case filterBuilderValueTypes.QUALITY_PROFILE:
return QualityProfileFilterBuilderRowValue;
case filterBuilderValueTypes.QUEUE_STATUS:
return QueueStatusFilterBuilderRowValue;
case filterBuilderValueTypes.SEASONS_MONITORED_STATUS:
return SeasonsMonitoredStatusFilterBuilderRowValue;

View File

@@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TagInput from 'Components/Form/Tag/TagInput';
import TagInput from 'Components/Form/TagInput';
import { filterBuilderTypes, filterBuilderValueTypes, kinds } from 'Helpers/Props';
import tagShape from 'Helpers/Props/Shapes/tagShape';
import convertToBytes from 'Utilities/Number/convertToBytes';

View File

@@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import TagInputTag from 'Components/Form/Tag/TagInputTag';
import TagInputTag from 'Components/Form/TagInputTag';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './FilterBuilderRowValueTag.css';

View File

@@ -1,67 +0,0 @@
import React from 'react';
import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
const statusTagList = [
{
id: 'queued',
get name() {
return translate('Queued');
},
},
{
id: 'paused',
get name() {
return translate('Paused');
},
},
{
id: 'downloading',
get name() {
return translate('Downloading');
},
},
{
id: 'completed',
get name() {
return translate('Completed');
},
},
{
id: 'failed',
get name() {
return translate('Failed');
},
},
{
id: 'warning',
get name() {
return translate('Warning');
},
},
{
id: 'delay',
get name() {
return translate('Delay');
},
},
{
id: 'downloadClientUnavailable',
get name() {
return translate('DownloadClientUnavailable');
},
},
{
id: 'fallback',
get name() {
return translate('Fallback');
},
},
];
function QueueStatusFilterBuilderRowValue(props: FilterBuilderRowValueProps) {
return <FilterBuilderRowValue {...props} tagList={statusTagList} />;
}
export default QueueStatusFilterBuilderRowValue;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
const statusTagList = [
const seriesStatusList = [
{
id: 'continuing',
get name() {
@@ -32,7 +32,7 @@ const statusTagList = [
function SeriesStatusFilterBuilderRowValue(props) {
return (
<FilterBuilderRowValue
tagList={statusTagList}
tagList={seriesStatusList}
{...props}
/>
);

View File

@@ -0,0 +1,98 @@
import jdu from 'jdu';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import AutoSuggestInput from './AutoSuggestInput';
class AutoCompleteInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
suggestions: []
};
}
//
// Control
getSuggestionValue(item) {
return item;
}
renderSuggestion(item) {
return item;
}
//
// Listeners
onInputChange = (event, { newValue }) => {
this.props.onChange({
name: this.props.name,
value: newValue
});
};
onInputBlur = () => {
this.setState({ suggestions: [] });
};
onSuggestionsFetchRequested = ({ value }) => {
const { values } = this.props;
const lowerCaseValue = jdu.replace(value).toLowerCase();
const filteredValues = values.filter((v) => {
return jdu.replace(v).toLowerCase().contains(lowerCaseValue);
});
this.setState({ suggestions: filteredValues });
};
onSuggestionsClearRequested = () => {
this.setState({ suggestions: [] });
};
//
// Render
render() {
const {
name,
value,
...otherProps
} = this.props;
const { suggestions } = this.state;
return (
<AutoSuggestInput
{...otherProps}
name={name}
value={value}
suggestions={suggestions}
getSuggestionValue={this.getSuggestionValue}
renderSuggestion={this.renderSuggestion}
onInputBlur={this.onInputBlur}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
/>
);
}
}
AutoCompleteInput.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.string,
values: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func.isRequired
};
AutoCompleteInput.defaultProps = {
value: ''
};
export default AutoCompleteInput;

View File

@@ -1,81 +0,0 @@
import jdu from 'jdu';
import React, { SyntheticEvent, useCallback, useState } from 'react';
import {
ChangeEvent,
SuggestionsFetchRequestedParams,
} from 'react-autosuggest';
import { InputChanged } from 'typings/inputs';
import AutoSuggestInput from './AutoSuggestInput';
interface AutoCompleteInputProps {
name: string;
value?: string;
values: string[];
onChange: (change: InputChanged<string>) => unknown;
}
function AutoCompleteInput({
name,
value = '',
values,
onChange,
...otherProps
}: AutoCompleteInputProps) {
const [suggestions, setSuggestions] = useState<string[]>([]);
const getSuggestionValue = useCallback((item: string) => {
return item;
}, []);
const renderSuggestion = useCallback((item: string) => {
return item;
}, []);
const handleInputChange = useCallback(
(_event: SyntheticEvent, { newValue }: ChangeEvent) => {
onChange({
name,
value: newValue,
});
},
[name, onChange]
);
const handleInputBlur = useCallback(() => {
setSuggestions([]);
}, [setSuggestions]);
const handleSuggestionsFetchRequested = useCallback(
({ value: newValue }: SuggestionsFetchRequestedParams) => {
const lowerCaseValue = jdu.replace(newValue).toLowerCase();
const filteredValues = values.filter((v) => {
return jdu.replace(v).toLowerCase().includes(lowerCaseValue);
});
setSuggestions(filteredValues);
},
[values, setSuggestions]
);
const handleSuggestionsClearRequested = useCallback(() => {
setSuggestions([]);
}, [setSuggestions]);
return (
<AutoSuggestInput
{...otherProps}
name={name}
value={value}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderSuggestion={renderSuggestion}
onInputChange={handleInputChange}
onInputBlur={handleInputBlur}
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
onSuggestionsClearRequested={handleSuggestionsClearRequested}
/>
);
}
export default AutoCompleteInput;

View File

@@ -0,0 +1,257 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import styles from './AutoSuggestInput.css';
class AutoSuggestInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._scheduleUpdate = null;
}
componentDidUpdate(prevProps) {
if (
this._scheduleUpdate &&
prevProps.suggestions !== this.props.suggestions
) {
this._scheduleUpdate();
}
}
//
// Control
renderInputComponent = (inputProps) => {
const { renderInputComponent } = this.props;
return (
<Reference>
{({ ref }) => {
if (renderInputComponent) {
return renderInputComponent(inputProps, ref);
}
return (
<div ref={ref}>
<input
{...inputProps}
/>
</div>
);
}}
</Reference>
);
};
renderSuggestionsContainer = ({ containerProps, children }) => {
return (
<Portal>
<Popper
placement='bottom-start'
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: this.onComputeMaxHeight
},
flip: {
padding: this.props.minHeight
}
}}
>
{({ ref: popperRef, style, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
return (
<div
ref={popperRef}
style={style}
className={children ? styles.suggestionsContainerOpen : undefined}
>
<div
{...containerProps}
style={{
maxHeight: style.maxHeight
}}
>
{children}
</div>
</div>
);
}}
</Popper>
</Portal>
);
};
//
// Listeners
onComputeMaxHeight = (data) => {
const {
top,
bottom,
width
} = data.offsets.reference;
const windowHeight = window.innerHeight;
if ((/^botton/).test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
data.styles.width = width;
return data;
};
onInputChange = (event, { newValue }) => {
this.props.onChange({
name: this.props.name,
value: newValue
});
};
onInputKeyDown = (event) => {
const {
name,
value,
suggestions,
onChange
} = this.props;
if (
event.key === 'Tab' &&
suggestions.length &&
suggestions[0] !== this.props.value
) {
event.preventDefault();
if (value) {
onChange({
name,
value: suggestions[0]
});
}
}
};
//
// Render
render() {
const {
forwardedRef,
className,
inputContainerClassName,
name,
value,
placeholder,
suggestions,
hasError,
hasWarning,
getSuggestionValue,
renderSuggestion,
onInputChange,
onInputKeyDown,
onInputFocus,
onInputBlur,
onSuggestionsFetchRequested,
onSuggestionsClearRequested,
onSuggestionSelected,
...otherProps
} = this.props;
const inputProps = {
className: classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning
),
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: onInputChange || this.onInputChange,
onKeyDown: onInputKeyDown || this.onInputKeyDown,
onFocus: onInputFocus,
onBlur: onInputBlur
};
const theme = {
container: inputContainerClassName,
containerOpen: styles.suggestionsContainerOpen,
suggestionsContainer: styles.suggestionsContainer,
suggestionsList: styles.suggestionsList,
suggestion: styles.suggestion,
suggestionHighlighted: styles.suggestionHighlighted
};
return (
<Manager>
<Autosuggest
{...otherProps}
ref={forwardedRef}
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderInputComponent={this.renderInputComponent}
renderSuggestionsContainer={this.renderSuggestionsContainer}
renderSuggestion={renderSuggestion}
onSuggestionSelected={onSuggestionSelected}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
/>
</Manager>
);
}
}
AutoSuggestInput.propTypes = {
forwardedRef: PropTypes.func,
className: PropTypes.string.isRequired,
inputContainerClassName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
placeholder: PropTypes.string,
suggestions: PropTypes.array.isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
enforceMaxHeight: PropTypes.bool.isRequired,
minHeight: PropTypes.number.isRequired,
maxHeight: PropTypes.number.isRequired,
getSuggestionValue: PropTypes.func.isRequired,
renderInputComponent: PropTypes.elementType,
renderSuggestion: PropTypes.func.isRequired,
onInputChange: PropTypes.func,
onInputKeyDown: PropTypes.func,
onInputFocus: PropTypes.func,
onInputBlur: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func,
onChange: PropTypes.func.isRequired
};
AutoSuggestInput.defaultProps = {
className: styles.input,
inputContainerClassName: styles.inputContainer,
enforceMaxHeight: true,
minHeight: 50,
maxHeight: 200
};
export default AutoSuggestInput;

View File

@@ -1,259 +0,0 @@
import classNames from 'classnames';
import React, {
FocusEvent,
FormEvent,
KeyboardEvent,
KeyboardEventHandler,
MutableRefObject,
ReactNode,
Ref,
SyntheticEvent,
useCallback,
useEffect,
useRef,
} from 'react';
import Autosuggest, {
AutosuggestPropsBase,
BlurEvent,
ChangeEvent,
RenderInputComponentProps,
RenderSuggestionsContainerParams,
} from 'react-autosuggest';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { InputChanged } from 'typings/inputs';
import styles from './AutoSuggestInput.css';
interface AutoSuggestInputProps<T>
extends Omit<AutosuggestPropsBase<T>, 'renderInputComponent' | 'inputProps'> {
forwardedRef?: MutableRefObject<Autosuggest<T> | null>;
className?: string;
inputContainerClassName?: string;
name: string;
value?: string;
placeholder?: string;
suggestions: T[];
hasError?: boolean;
hasWarning?: boolean;
enforceMaxHeight?: boolean;
minHeight?: number;
maxHeight?: number;
renderInputComponent?: (
inputProps: RenderInputComponentProps,
ref: Ref<HTMLDivElement>
) => ReactNode;
onInputChange: (
event: FormEvent<HTMLElement>,
params: ChangeEvent
) => unknown;
onInputKeyDown?: KeyboardEventHandler<HTMLElement>;
onInputFocus?: (event: SyntheticEvent) => unknown;
onInputBlur: (
event: FocusEvent<HTMLElement>,
params?: BlurEvent<T>
) => unknown;
onChange?: (change: InputChanged<T>) => unknown;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function AutoSuggestInput<T = any>(props: AutoSuggestInputProps<T>) {
const {
// TODO: forwaredRef should be replaces with React.forwardRef
forwardedRef,
className = styles.input,
inputContainerClassName = styles.inputContainer,
name,
value = '',
placeholder,
suggestions,
enforceMaxHeight = true,
hasError,
hasWarning,
minHeight = 50,
maxHeight = 200,
getSuggestionValue,
renderSuggestion,
renderInputComponent,
onInputChange,
onInputKeyDown,
onInputFocus,
onInputBlur,
onSuggestionsFetchRequested,
onSuggestionsClearRequested,
onSuggestionSelected,
onChange,
...otherProps
} = props;
const updater = useRef<(() => void) | null>(null);
const previousSuggestions = usePrevious(suggestions);
const handleComputeMaxHeight = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(data: any) => {
const { top, bottom, width } = data.offsets.reference;
if (enforceMaxHeight) {
data.styles.maxHeight = maxHeight;
} else {
const windowHeight = window.innerHeight;
if (/^botton/.test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
}
data.styles.width = width;
return data;
},
[enforceMaxHeight, maxHeight]
);
const createRenderInputComponent = useCallback(
(inputProps: RenderInputComponentProps) => {
return (
<Reference>
{({ ref }) => {
if (renderInputComponent) {
return renderInputComponent(inputProps, ref);
}
return (
<div ref={ref}>
<input {...inputProps} />
</div>
);
}}
</Reference>
);
},
[renderInputComponent]
);
const renderSuggestionsContainer = useCallback(
({ containerProps, children }: RenderSuggestionsContainerParams) => {
return (
<Portal>
<Popper
placement="bottom-start"
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: handleComputeMaxHeight,
},
flip: {
padding: minHeight,
},
}}
>
{({ ref: popperRef, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
return (
<div
ref={popperRef}
style={style}
className={
children ? styles.suggestionsContainerOpen : undefined
}
>
<div
{...containerProps}
style={{
maxHeight: style.maxHeight,
}}
>
{children}
</div>
</div>
);
}}
</Popper>
</Portal>
);
},
[minHeight, handleComputeMaxHeight]
);
const handleInputKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (
event.key === 'Tab' &&
suggestions.length &&
suggestions[0] !== value
) {
event.preventDefault();
if (value) {
onSuggestionSelected?.(event, {
suggestion: suggestions[0],
suggestionValue: value,
suggestionIndex: 0,
sectionIndex: null,
method: 'enter',
});
}
}
},
[value, suggestions, onSuggestionSelected]
);
const inputProps = {
className: classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning
),
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: onInputChange,
onKeyDown: onInputKeyDown || handleInputKeyDown,
onFocus: onInputFocus,
onBlur: onInputBlur,
};
const theme = {
container: inputContainerClassName,
containerOpen: styles.suggestionsContainerOpen,
suggestionsContainer: styles.suggestionsContainer,
suggestionsList: styles.suggestionsList,
suggestion: styles.suggestion,
suggestionHighlighted: styles.suggestionHighlighted,
};
useEffect(() => {
if (updater.current && suggestions !== previousSuggestions) {
updater.current();
}
}, [suggestions, previousSuggestions]);
return (
<Manager>
<Autosuggest
{...otherProps}
ref={forwardedRef}
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderInputComponent={createRenderInputComponent}
renderSuggestionsContainer={renderSuggestionsContainer}
renderSuggestion={renderSuggestion}
onSuggestionSelected={onSuggestionSelected}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
/>
</Manager>
);
}
export default AutoSuggestInput;

View File

@@ -0,0 +1,84 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import FormInputButton from './FormInputButton';
import TextInput from './TextInput';
import styles from './CaptchaInput.css';
function CaptchaInput(props) {
const {
className,
name,
value,
hasError,
hasWarning,
refreshing,
siteKey,
secretToken,
onChange,
onRefreshPress,
onCaptchaChange
} = props;
return (
<div>
<div className={styles.captchaInputWrapper}>
<TextInput
className={classNames(
className,
styles.hasButton,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)}
name={name}
value={value}
onChange={onChange}
/>
<FormInputButton
onPress={onRefreshPress}
>
<Icon
name={icons.REFRESH}
isSpinning={refreshing}
/>
</FormInputButton>
</div>
{
!!siteKey && !!secretToken &&
<div className={styles.recaptchaWrapper}>
<ReCAPTCHA
sitekey={siteKey}
stoken={secretToken}
onChange={onCaptchaChange}
/>
</div>
}
</div>
);
}
CaptchaInput.propTypes = {
className: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
refreshing: PropTypes.bool.isRequired,
siteKey: PropTypes.string,
secretToken: PropTypes.string,
onChange: PropTypes.func.isRequired,
onRefreshPress: PropTypes.func.isRequired,
onCaptchaChange: PropTypes.func.isRequired
};
CaptchaInput.defaultProps = {
className: styles.input,
value: ''
};
export default CaptchaInput;

View File

@@ -1,118 +0,0 @@
import classNames from 'classnames';
import React, { useCallback, useEffect } from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Icon from 'Components/Icon';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons } from 'Helpers/Props';
import {
getCaptchaCookie,
refreshCaptcha,
resetCaptcha,
} from 'Store/Actions/captchaActions';
import { InputChanged } from 'typings/inputs';
import FormInputButton from './FormInputButton';
import TextInput from './TextInput';
import styles from './CaptchaInput.css';
interface CaptchaInputProps {
className?: string;
name: string;
value?: string;
provider: string;
providerData: object;
hasError?: boolean;
hasWarning?: boolean;
refreshing: boolean;
siteKey?: string;
secretToken?: string;
onChange: (change: InputChanged<string>) => unknown;
}
function CaptchaInput({
className = styles.input,
name,
value = '',
provider,
providerData,
hasError,
hasWarning,
refreshing,
siteKey,
secretToken,
onChange,
}: CaptchaInputProps) {
const { token } = useSelector((state: AppState) => state.captcha);
const dispatch = useDispatch();
const previousToken = usePrevious(token);
const handleCaptchaChange = useCallback(
(token: string | null) => {
// If the captcha has expired `captchaResponse` will be null.
// In the event it's null don't try to get the captchaCookie.
// TODO: Should we clear the cookie? or reset the captcha?
if (!token) {
return;
}
dispatch(
getCaptchaCookie({
provider,
providerData,
captchaResponse: token,
})
);
},
[provider, providerData, dispatch]
);
const handleRefreshPress = useCallback(() => {
dispatch(refreshCaptcha({ provider, providerData }));
}, [provider, providerData, dispatch]);
useEffect(() => {
if (token && token !== previousToken) {
onChange({ name, value: token });
}
}, [name, token, previousToken, onChange]);
useEffect(() => {
dispatch(resetCaptcha());
}, [dispatch]);
return (
<div>
<div className={styles.captchaInputWrapper}>
<TextInput
className={classNames(
className,
styles.hasButton,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)}
name={name}
value={value}
onChange={onChange}
/>
<FormInputButton onPress={handleRefreshPress}>
<Icon name={icons.REFRESH} isSpinning={refreshing} />
</FormInputButton>
</div>
{siteKey && secretToken ? (
<div className={styles.recaptchaWrapper}>
<ReCAPTCHA
sitekey={siteKey}
stoken={secretToken}
onChange={handleCaptchaChange}
/>
</div>
) : null}
</div>
);
}
export default CaptchaInput;

View File

@@ -0,0 +1,98 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { getCaptchaCookie, refreshCaptcha, resetCaptcha } from 'Store/Actions/captchaActions';
import CaptchaInput from './CaptchaInput';
function createMapStateToProps() {
return createSelector(
(state) => state.captcha,
(captcha) => {
return captcha;
}
);
}
const mapDispatchToProps = {
refreshCaptcha,
getCaptchaCookie,
resetCaptcha
};
class CaptchaInputConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps) {
const {
name,
token,
onChange
} = this.props;
if (token && token !== prevProps.token) {
onChange({ name, value: token });
}
}
componentWillUnmount = () => {
this.props.resetCaptcha();
};
//
// Listeners
onRefreshPress = () => {
const {
provider,
providerData
} = this.props;
this.props.refreshCaptcha({ provider, providerData });
};
onCaptchaChange = (captchaResponse) => {
// If the captcha has expired `captchaResponse` will be null.
// In the event it's null don't try to get the captchaCookie.
// TODO: Should we clear the cookie? or reset the captcha?
if (!captchaResponse) {
return;
}
const {
provider,
providerData
} = this.props;
this.props.getCaptchaCookie({ provider, providerData, captchaResponse });
};
//
// Render
render() {
return (
<CaptchaInput
{...this.props}
onRefreshPress={this.onRefreshPress}
onCaptchaChange={this.onCaptchaChange}
/>
);
}
}
CaptchaInputConnector.propTypes = {
provider: PropTypes.string.isRequired,
providerData: PropTypes.object.isRequired,
name: PropTypes.string.isRequired,
token: PropTypes.string,
onChange: PropTypes.func.isRequired,
refreshCaptcha: PropTypes.func.isRequired,
getCaptchaCookie: PropTypes.func.isRequired,
resetCaptcha: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(CaptchaInputConnector);

View File

@@ -0,0 +1,191 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import { icons, kinds } from 'Helpers/Props';
import FormInputHelpText from './FormInputHelpText';
import styles from './CheckInput.css';
class CheckInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._checkbox = null;
}
componentDidMount() {
this.setIndeterminate();
}
componentDidUpdate() {
this.setIndeterminate();
}
//
// Control
setIndeterminate() {
if (!this._checkbox) {
return;
}
const {
value,
uncheckedValue,
checkedValue
} = this.props;
this._checkbox.indeterminate = value !== uncheckedValue && value !== checkedValue;
}
toggleChecked = (checked, shiftKey) => {
const {
name,
value,
checkedValue,
uncheckedValue
} = this.props;
const newValue = checked ? checkedValue : uncheckedValue;
if (value !== newValue) {
this.props.onChange({
name,
value: newValue,
shiftKey
});
}
};
//
// Listeners
setRef = (ref) => {
this._checkbox = ref;
};
onClick = (event) => {
if (this.props.isDisabled) {
return;
}
const shiftKey = event.nativeEvent.shiftKey;
const checked = !this._checkbox.checked;
event.preventDefault();
this.toggleChecked(checked, shiftKey);
};
onChange = (event) => {
const checked = event.target.checked;
const shiftKey = event.nativeEvent.shiftKey;
this.toggleChecked(checked, shiftKey);
};
//
// Render
render() {
const {
className,
containerClassName,
name,
value,
checkedValue,
uncheckedValue,
helpText,
helpTextWarning,
isDisabled,
kind
} = this.props;
const isChecked = value === checkedValue;
const isUnchecked = value === uncheckedValue;
const isIndeterminate = !isChecked && !isUnchecked;
const isCheckClass = `${kind}IsChecked`;
return (
<div className={containerClassName}>
<label
className={styles.label}
onClick={this.onClick}
>
<input
ref={this.setRef}
className={styles.checkbox}
type="checkbox"
name={name}
checked={isChecked}
disabled={isDisabled}
onChange={this.onChange}
/>
<div
className={classNames(
className,
isChecked ? styles[isCheckClass] : styles.isNotChecked,
isIndeterminate && styles.isIndeterminate,
isDisabled && styles.isDisabled
)}
>
{
isChecked &&
<Icon name={icons.CHECK} />
}
{
isIndeterminate &&
<Icon name={icons.CHECK_INDETERMINATE} />
}
</div>
{
helpText &&
<FormInputHelpText
className={styles.helpText}
text={helpText}
/>
}
{
!helpText && helpTextWarning &&
<FormInputHelpText
className={styles.helpText}
text={helpTextWarning}
isWarning={true}
/>
}
</label>
</div>
);
}
}
CheckInput.propTypes = {
className: PropTypes.string.isRequired,
containerClassName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
checkedValue: PropTypes.bool,
uncheckedValue: PropTypes.bool,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
helpText: PropTypes.string,
helpTextWarning: PropTypes.string,
isDisabled: PropTypes.bool,
kind: PropTypes.oneOf(kinds.all).isRequired,
onChange: PropTypes.func.isRequired
};
CheckInput.defaultProps = {
className: styles.input,
containerClassName: styles.container,
checkedValue: true,
uncheckedValue: false,
kind: kinds.PRIMARY
};
export default CheckInput;

View File

@@ -1,141 +0,0 @@
import classNames from 'classnames';
import React, { SyntheticEvent, useCallback, useEffect, useRef } from 'react';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { CheckInputChanged } from 'typings/inputs';
import FormInputHelpText from './FormInputHelpText';
import styles from './CheckInput.css';
interface ChangeEvent<T = Element> extends SyntheticEvent<T, MouseEvent> {
target: EventTarget & T;
}
interface CheckInputProps {
className?: string;
containerClassName?: string;
name: string;
checkedValue?: boolean;
uncheckedValue?: boolean;
value?: string | boolean;
helpText?: string;
helpTextWarning?: string;
isDisabled?: boolean;
kind?: Extract<Kind, keyof typeof styles>;
onChange: (changes: CheckInputChanged) => void;
}
function CheckInput(props: CheckInputProps) {
const {
className = styles.input,
containerClassName = styles.container,
name,
value,
checkedValue = true,
uncheckedValue = false,
helpText,
helpTextWarning,
isDisabled,
kind = 'primary',
onChange,
} = props;
const inputRef = useRef<HTMLInputElement>(null);
const isChecked = value === checkedValue;
const isUnchecked = value === uncheckedValue;
const isIndeterminate = !isChecked && !isUnchecked;
const isCheckClass: keyof typeof styles = `${kind}IsChecked`;
const toggleChecked = useCallback(
(checked: boolean, shiftKey: boolean) => {
const newValue = checked ? checkedValue : uncheckedValue;
if (value !== newValue) {
onChange({
name,
value: newValue,
shiftKey,
});
}
},
[name, value, checkedValue, uncheckedValue, onChange]
);
const handleClick = useCallback(
(event: SyntheticEvent<HTMLElement, MouseEvent>) => {
if (isDisabled) {
return;
}
const shiftKey = event.nativeEvent.shiftKey;
const checked = !(inputRef.current?.checked ?? false);
event.preventDefault();
toggleChecked(checked, shiftKey);
},
[isDisabled, toggleChecked]
);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const checked = event.target.checked;
const shiftKey = event.nativeEvent.shiftKey;
toggleChecked(checked, shiftKey);
},
[toggleChecked]
);
useEffect(() => {
if (!inputRef.current) {
return;
}
inputRef.current.indeterminate =
value !== uncheckedValue && value !== checkedValue;
}, [value, uncheckedValue, checkedValue]);
return (
<div className={containerClassName}>
<label className={styles.label} onClick={handleClick}>
<input
ref={inputRef}
className={styles.checkbox}
type="checkbox"
name={name}
checked={isChecked}
disabled={isDisabled}
onChange={handleChange}
/>
<div
className={classNames(
className,
isChecked ? styles[isCheckClass] : styles.isNotChecked,
isIndeterminate && styles.isIndeterminate,
isDisabled && styles.isDisabled
)}
>
{isChecked ? <Icon name={icons.CHECK} /> : null}
{isIndeterminate ? <Icon name={icons.CHECK_INDETERMINATE} /> : null}
</div>
{helpText ? (
<FormInputHelpText className={styles.helpText} text={helpText} />
) : null}
{!helpText && helpTextWarning ? (
<FormInputHelpText
className={styles.helpText}
text={helpTextWarning}
isWarning={true}
/>
) : null}
</label>
</div>
);
}
export default CheckInput;

View File

@@ -3,6 +3,6 @@
}
.input {
composes: input from '~Components/Form/Tag/TagInput.css';
composes: input from '~./TagInput.css';
composes: hasButton from '~Components/Form/Input.css';
}

View File

@@ -0,0 +1,106 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import tagShape from 'Helpers/Props/Shapes/tagShape';
import FormInputButton from './FormInputButton';
import TagInput from './TagInput';
import styles from './DeviceInput.css';
class DeviceInput extends Component {
onTagAdd = (device) => {
const {
name,
value,
onChange
} = this.props;
// New tags won't have an ID, only a name.
const deviceId = device.id || device.name;
onChange({
name,
value: [...value, deviceId]
});
};
onTagDelete = ({ index }) => {
const {
name,
value,
onChange
} = this.props;
const newValue = value.slice();
newValue.splice(index, 1);
onChange({
name,
value: newValue
});
};
//
// Render
render() {
const {
className,
name,
items,
selectedDevices,
hasError,
hasWarning,
isFetching,
onRefreshPress
} = this.props;
return (
<div className={className}>
<TagInput
inputContainerClassName={styles.input}
name={name}
tags={selectedDevices}
tagList={items}
allowNew={true}
minQueryLength={0}
hasError={hasError}
hasWarning={hasWarning}
onTagAdd={this.onTagAdd}
onTagDelete={this.onTagDelete}
/>
<FormInputButton
onPress={onRefreshPress}
>
<Icon
name={icons.REFRESH}
isSpinning={isFetching}
/>
</FormInputButton>
</div>
);
}
}
DeviceInput.propTypes = {
className: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired,
items: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
selectedDevices: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onRefreshPress: PropTypes.func.isRequired
};
DeviceInput.defaultProps = {
className: styles.deviceInputWrapper,
inputClassName: styles.input
};
export default DeviceInput;

View File

@@ -0,0 +1,104 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions';
import DeviceInput from './DeviceInput';
function createMapStateToProps() {
return createSelector(
(state, { value }) => value,
(state) => state.providerOptions.devices || defaultState,
(value, devices) => {
return {
...devices,
selectedDevices: value.map((valueDevice) => {
// Disable equality ESLint rule so we don't need to worry about
// a type mismatch between the value items and the device ID.
// eslint-disable-next-line eqeqeq
const device = devices.items.find((d) => d.id == valueDevice);
if (device) {
return {
id: device.id,
name: `${device.name} (${device.id})`
};
}
return {
id: valueDevice,
name: `Unknown (${valueDevice})`
};
})
};
}
);
}
const mapDispatchToProps = {
dispatchFetchOptions: fetchOptions,
dispatchClearOptions: clearOptions
};
class DeviceInputConnector extends Component {
//
// Lifecycle
componentDidMount = () => {
this._populate();
};
componentWillUnmount = () => {
this.props.dispatchClearOptions({ section: 'devices' });
};
//
// Control
_populate() {
const {
provider,
providerData,
dispatchFetchOptions
} = this.props;
dispatchFetchOptions({
section: 'devices',
action: 'getDevices',
provider,
providerData
});
}
//
// Listeners
onRefreshPress = () => {
this._populate();
};
//
// Render
render() {
return (
<DeviceInput
{...this.props}
onRefreshPress={this.onRefreshPress}
/>
);
}
}
DeviceInputConnector.propTypes = {
provider: PropTypes.string.isRequired,
providerData: PropTypes.object.isRequired,
name: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
dispatchFetchOptions: PropTypes.func.isRequired,
dispatchClearOptions: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(DeviceInputConnector);

Some files were not shown because too many files have changed in this diff Show More