├── .github └── FUNDING.yml ├── .gitignore ├── .npmignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── src ├── Epg │ ├── Epg.tsx │ ├── __tests__ │ │ └── Epg.test.tsx │ ├── components │ │ ├── Channel.tsx │ │ ├── Channels.tsx │ │ ├── Layout.tsx │ │ ├── Line │ │ │ ├── Line.tsx │ │ │ └── index.ts │ │ ├── Loader.tsx │ │ ├── Program.tsx │ │ ├── Timeline.tsx │ │ ├── __tests__ │ │ │ ├── Channel.test.tsx │ │ │ ├── Layout.test.tsx │ │ │ └── Program.test.tsx │ │ └── index.ts │ ├── helpers │ │ ├── __tests__ │ │ │ ├── common.test.ts │ │ │ └── time.test.ts │ │ ├── common.ts │ │ ├── enums.ts │ │ ├── epg.ts │ │ ├── index.ts │ │ ├── interfaces.ts │ │ ├── time.ts │ │ ├── types.ts │ │ └── variables.ts │ ├── hooks │ │ ├── __tests__ │ │ │ ├── useLayout.test.tsx │ │ │ ├── useProgram.test.tsx │ │ │ └── useTimeline.test.tsx │ │ ├── index.ts │ │ ├── useEpg.tsx │ │ ├── useInterval.tsx │ │ ├── useLayout.tsx │ │ ├── useLine.tsx │ │ ├── useProgram.tsx │ │ └── useTimeline.tsx │ ├── index.ts │ ├── styles │ │ ├── Channel.styles.ts │ │ ├── Channels.styles.ts │ │ ├── Epg.styles.ts │ │ ├── Line.styles.ts │ │ ├── Loader.styles.ts │ │ ├── Program.styles.ts │ │ ├── Timeline.styles.ts │ │ ├── global.styles.ts │ │ └── index.ts │ ├── test │ │ ├── db │ │ │ ├── channels.ts │ │ │ ├── epg.ts │ │ │ └── index.ts │ │ ├── helpers.ts │ │ ├── index.ts │ │ └── test-utils.tsx │ └── theme │ │ ├── emotion.d.ts │ │ ├── index.ts │ │ └── theme.ts └── index.ts ├── tsconfig.json └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [karolkozer] 2 | open_collective: planby 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | package-lock.json 7 | yarn.lock -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /images 2 | /src 3 | jest.config.js 4 | tsconfig.json 5 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct - Planby 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to a positive environment for our 15 | community include: 16 | 17 | * Demonstrating empathy and kindness toward other people 18 | * Being respectful of differing opinions, viewpoints, and experiences 19 | * Giving and gracefully accepting constructive feedback 20 | * Accepting responsibility and apologizing to those affected by our mistakes, 21 | and learning from the experience 22 | * Focusing on what is best not just for us as individuals, but for the 23 | overall community 24 | 25 | Examples of unacceptable behavior include: 26 | 27 | * The use of sexualized language or imagery, and sexual attention or 28 | advances 29 | * Trolling, insulting or derogatory comments, and personal or political attacks 30 | * Public or private harassment 31 | * Publishing others' private information, such as a physical or email 32 | address, without their explicit permission 33 | * Other conduct which could reasonably be considered inappropriate in a 34 | professional setting 35 | 36 | ## Our Responsibilities 37 | 38 | Project maintainers are responsible for clarifying and enforcing our standards of 39 | acceptable behavior and will take appropriate and fair corrective action in 40 | response to any instances of unacceptable behavior. 41 | 42 | Project maintainers have the right and responsibility to remove, edit, or reject 43 | comments, commits, code, wiki edits, issues, and other contributions that are 44 | not aligned to this Code of Conduct, or to ban 45 | temporarily or permanently any contributor for other behaviors that they deem 46 | inappropriate, threatening, offensive, or harmful. 47 | 48 | ## Scope 49 | 50 | This Code of Conduct applies within all community spaces, and also applies when 51 | an individual is officially representing the community in public spaces. 52 | Examples of representing our community include using an official e-mail address, 53 | posting via an official social media account, or acting as an appointed 54 | representative at an online or offline event. 55 | 56 | ## Enforcement 57 | 58 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 59 | reported to the community leaders responsible for enforcement at . 60 | All complaints will be reviewed and investigated promptly and fairly. 61 | 62 | All community leaders are obligated to respect the privacy and security of the 63 | reporter of any incident. 64 | 65 | ## Attribution 66 | 67 | This Code of Conduct is adapted from the [Contributor Covenant](https://contributor-covenant.org/), version 68 | [1.4](https://www.contributor-covenant.org/version/1/4/code-of-conduct/code_of_conduct.md) and 69 | [2.0](https://www.contributor-covenant.org/version/2/0/code_of_conduct/code_of_conduct.md). 70 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Planby 2 | 3 | First off all, thanks for taking the time to contribute! ❤️ 4 | 5 | All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 6 | 7 | > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: 8 | > 9 | > - Star the project 10 | > - Tweet about it 11 | > - Refer this project in your project's readme 12 | > - Mention the project at local meetups and tell your friends/colleagues 13 | 14 | ## Table of Contents 15 | 16 | - [Code of Conduct](#code-of-conduct) 17 | - [I Have a Question](#i-have-a-question) 18 | - [I Want To Contribute](#i-want-to-contribute) 19 | - [Reporting Bugs](#reporting-bugs) 20 | - [Suggesting Enhancements](#suggesting-enhancements) 21 | - [Your First Code Contribution](#your-first-code-contribution) 22 | - [Improving The Documentation](#improving-the-documentation) 23 | - [Styleguides](#styleguides) 24 | - [Commit Messages](#commit-messages) 25 | - [Join The Project Team](#join-the-project-team) 26 | 27 | ## I Have a Question 28 | 29 | > If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/karolkozer/planby). 30 | 31 | Before you ask a question, it is best to search for existing [Issues](https://github.com/karolkozer/planby/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. 32 | 33 | If you then still feel the need to ask a question and need clarification, we recommend the following: 34 | 35 | - Open an [Issue](https://github.com/karolkozer/planby/issues/new). 36 | - Provide as much context as you can about what you're running into. 37 | - Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. 38 | 39 | We will then take care of the issue as soon as possible. 40 | 41 | ## I Want To Contribute 42 | 43 | ### Reporting Bugs 44 | 45 | #### Before Submitting a Bug Report 46 | 47 | A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. 48 | 49 | - Make sure that you are using the latest version. 50 | - Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://github.com/karolkozer/planby). If you are looking for support, you might want to check [this section](#i-have-a-question)). 51 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/karolkozer/planbyissues?q=label%3Abug). 52 | - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. 53 | - Collect information about the bug: 54 | - Stack trace (Traceback) 55 | - OS, Platform and Version (Windows, Linux, macOS) 56 | - Version of the compiler, runtime environment, package manager, depending on what seems relevant. 57 | - Possibly your input and the output 58 | - Can you reliably reproduce the issue? And can you also reproduce it with older versions? 59 | 60 | We use GitHub issues to track bugs and errors. 61 | 62 | We use GitHub issues to track bugs and errors. 63 | 64 | If you run into an issue with the project: 65 | 66 | - Open an [Issue](https://github.com/karolkozer/planby/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) 67 | - Explain the behavior you would expect and the actual behavior. 68 | - Please provide as much context as possible and describe the _reproduction steps_ that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. 69 | - Provide the information you collected in the previous section. 70 | 71 | Once it's filed: 72 | 73 | - The project team will label the issue accordingly. 74 | - A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. 75 | - If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution). 76 | 77 | 78 | 79 | ### Suggesting Enhancements 80 | 81 | This section guides you through submitting an enhancement suggestion for Planby, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. 82 | 83 | #### Before Submitting an Enhancement 84 | 85 | - Make sure that you are using the latest version. 86 | - Read the [documentation](https://github.com/karolkozer/planby) carefully and find out if the functionality is already covered, maybe by an individual configuration. 87 | - Perform a [search](https://github.com/karolkozer/planby/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. 88 | - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library. 89 | 90 | #### How Do I Submit a Good Enhancement Suggestion? 91 | 92 | Enhancement suggestions are tracked as [GitHub issues](https://github.com/karolkozer/planby/issues). 93 | 94 | - Use a **clear and descriptive title** for the issue to identify the suggestion. 95 | - Provide a **step-by-step description of the suggested enhancement** in as many details as possible. 96 | - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. 97 | - You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. 98 | - **Explain why this enhancement would be useful** to most Planby users. You may also want to point out the other projects that solved it better and which could serve as inspiration. 99 | 100 | 101 | 102 | ### Your First Code Contribution 103 | 104 | 108 | 109 | ### Improving The Documentation 110 | 111 | 115 | 116 | ## Styleguides 117 | 118 | ### Commit Messages 119 | 120 | 123 | 124 | ## Join The Project Team 125 | 126 | 127 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Custom License - All Rights Reserved 2 | 3 | Copyright (c) 2025 Nessprim Karol Kozer 4 | 5 | Operator hereby grants a non-exclusive, free of charge, non-transferable license i.e. without the right to grant sublicense to use the Software. 6 | The license does not authorize the Company or the User to use the Software for purposes other than use the Software with intended purpose. 7 | 8 | The Company and the User has no right to reproduce, sell or otherwise market or distribute the Software in whole or in part, in any form, in particular, transmit or make available in computer systems and networks, or any other communication. 9 | 10 | It’s not allowed: 11 | 12 | 1. repeated and systematic downloading or re-using of information from the Software, contrary to normal use and resulting in an unjustified violation of the Operator’s legitimate interests; 13 | 2. repeated and systematic downloading or re-using contrary to normal use and resulting in an unjustified violation of the legitimate interests of the Operator; 14 | 3. copying parts of the database beyond normal personal use. 15 | 16 | THE PLATFORM IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE PLATFORM OR THE USE OR OTHER DEALINGS IN THE 22 | PLATFORM. 23 | 24 | The above note is only a shortened version. Its full version is presented in the Terms & Conditions and should be interpreted and applied in accordance with their content. 25 | 26 | This license does not grant you any rights to trademarks, service marks, or 27 | any other intellectual property of the author. For any specific licensing 28 | needs or questions regarding this license, please contact the author. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | Planby logo 4 | 5 |
6 | 7 |
8 | 9 | npm 10 | 11 | 12 | downloads 13 | 14 | 15 | downloads 16 | 17 | Support us 18 |
19 | 20 | # 🔥 An exclusive new experience — React Native support is on its way to Planby! 🔥 21 | 22 | ## React Native: To request beta access, email us at contact@planby.app with your position and company name. 23 | 24 | ## Description 25 | 26 | Planby is a React based component for a quick implementation of Epg, schedules, live streaming, music events, timelines and many more ideas. It uses a custom virtual view which allows you to operate on a really big number of data. The component has a simple API that you can easily integrate with other third party UI libraries. The component theme is customised to the needs of the application design. 27 | 28 |
29 | 30 | Planby preview 31 | 32 |
33 |
34 | 35 | Planby preview 36 | 37 |
38 |
39 | 40 | Planby preview 41 | 42 |
43 |
44 | 45 | Planby preview 46 | 47 |
48 | 49 | ## Codesandbox example 50 | 51 | [Live example - Codesandbox](https://codesandbox.io/s/5o3tsy) 52 | 53 | [Live example - Typescript Codesandbox](https://codesandbox.io/s/planby-epg-demo-ts-lp66v5) 54 | 55 | [Live example - website with control panel](https://planby.netlify.app/) 56 | 57 | ## Testimonials 58 | 59 |
60 | 61 | Planby preview 62 | 63 |
64 |
65 | 66 | Planby preview 67 | 68 |
69 | 70 | ## 🚀 [Become a Sponsor!](https://opencollective.com/planby) 🚀 71 | 72 | Become a sponsor, support, and help us in continuing our development. -> [Opencollective](https://opencollective.com/planby) 73 | 74 | ## Getting Started 75 | 76 | ### Installation 77 | 78 | - yarn 79 | 80 | ```sh 81 | yarn add planby 82 | ``` 83 | 84 | - npm 85 | 86 | ```sh 87 | npm install planby 88 | ``` 89 | 90 | ## Usage 91 | 92 | ```tsx 93 | import { useEpg, Epg, Layout } from 'planby'; 94 | 95 | const channels = React.useMemo( 96 | () => [ 97 | { 98 | logo: 'https://via.placeholder.com', 99 | uuid: '10339a4b-7c48-40ab-abad-f3bcaf95d9fa', 100 | ... 101 | }, 102 | ], 103 | [] 104 | ); 105 | 106 | const epg = React.useMemo( 107 | () => [ 108 | { 109 | channelUuid: '30f5ff1c-1346-480a-8047-a999dd908c1e', 110 | description: 111 | 'Ut anim nisi consequat minim deserunt...', 112 | id: 'b67ccaa3-3dd2-4121-8256-33dbddc7f0e6', 113 | image: 'https://via.placeholder.com', 114 | since: "2022-02-02T23:50:00", 115 | till: "2022-02-02T00:55:00", 116 | title: 'Title', 117 | ... 118 | }, 119 | ], 120 | [] 121 | ); 122 | 123 | const { 124 | getEpgProps, 125 | getLayoutProps, 126 | onScrollToNow, 127 | onScrollLeft, 128 | onScrollRight, 129 | } = useEpg({ 130 | epg, 131 | channels, 132 | startDate: '2022-02-02T00:00:00' 133 | }); 134 | 135 | return ( 136 |
137 |
138 | 139 | 142 | 143 |
144 |
145 | ); 146 | ``` 147 | 148 | or 149 | 150 | #### Custom width and height 151 | 152 | ```tsx 153 | const { 154 | getEpgProps, 155 | getLayoutProps, 156 | ... 157 | } = useEpg({ 158 | epg, 159 | channels, 160 | startDate: '2022/02/02', // or 2022-02-02T00:00:00 161 | width: 1200, 162 | height: 600 163 | }); 164 | 165 | return ( 166 |
167 | 168 | 171 | 172 |
173 | 174 | ``` 175 | 176 | or 177 | 178 | #### Time range 179 | 180 | ```tsx 181 | const { 182 | getEpgProps, 183 | getLayoutProps, 184 | ... 185 | } = useEpg({ 186 | epg, 187 | channels, 188 | startDate: '2022-02-02T10:00:00', 189 | endDate: '2022-02-02T20:00:00', 190 | width: 1200, 191 | height: 600 192 | }); 193 | 194 | return ( 195 |
196 | 197 | 200 | 201 |
202 | 203 | ``` 204 | 205 | ## API 206 | 207 | ### useEpg 208 | 209 | #### Options 210 | 211 | Available options in useEpg 212 | 213 | | Property | Type | Status | Description | Access | 214 | | ------------------------ | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | 215 | | `channels` | `array` | required | Array with channels data | | 216 | | `epg` | `array` | required | Array with EPG data | | 217 | | `width` | `number` | optional | EPG width | | 218 | | `height` | `number` | optional | EPG height | | 219 | | `sidebarWidth` | `number` | optional | Width of the sidebar with channels | | 220 | | `timelineHeight` | `number` | optional | Height of the timeline | `PRO` | 221 | | `itemHeight` | `number` | optional | Height of channels and programs in the EPG. Default value is 80 | | 222 | | `dayWidth` | `number` | optional | Width of the day. Default value is 7200. Calculation to set up day width with own hour width value e.g., 24h \* 300px (your custom hour width) = 7200px -> `dayWidth` | | 223 | | `startDate` | `string` | optional | Date format `2022/02/02` or `2022-02-02T00:00:00`. You can set your own start time, e.g., `2022-02-02T10:00:00`, `2022-02-02T14:00:00`, etc. Full clock hours only | | 224 | | `endDate` | `string` | optional | Date format `2022-02-02T00:00:00`, `2022-02-02T20:00:00`, etc. Must be within the same 24-hour period as `startDate`. Full clock hours only. Scroll through `multiple days` and timeline mode is available only in `PRO` plan. | `PRO` | 225 | | `hoursInDays` | `array` | optional | Set start time and end time of each day in `multiple days` feature if your data for each day has some time spaces between items in the day. | `PRO` | 226 | | `initialScrollPositions` | `object` | optional | Set initial scroll position in Layout, e.g., `initialScrollPositions: { top: 500, left: 800 }` | `PRO` | 227 | | `liveRefreshTime` | `number` | optional | Live refresh time of the events. Default value is 120 sec. | `PRO` | 228 | | `isBaseTimeFormat` | `boolean` | optional | Convert to 12-hour format, e.g., `2:00am`, `4:00pm`, etc. Default value is false. | | 229 | | `isCurrentTime` | `boolean` | optional | Show current time in Timeline. Default value is false. | `PRO` | 230 | | `isInitialScrollToNow` | `boolean` | optional | Scroll to the current live element. | `PRO` | 231 | | `isVerticalMode` | `boolean` | optional | Show Timeline in vertical view. Default value is false. | `PRO` | 232 | | `isResize` | `boolean` | optional | Possibility to resize the element. | `PRO` | 233 | | `isSidebar` | `boolean` | optional | Show/hide sidebar | | 234 | | `isTimeline` | `boolean` | optional | Show/hide timeline | | 235 | | `isLine` | `boolean` | optional | Show/hide line | | 236 | | `isRTL` | `boolean` | optional | Change direction to RTL or LTR. Default value is false. | `PRO` | 237 | | `theme` | `object` | optional | Object with theme schema | | 238 | | `timezone` | `object` | optional | Convert and display data from UTC format to your own time zone | `PRO` | 239 | | `areas` | `array` | optional | Area gives possibilities to add field ranges to the Timeline layout. | `PRO` | 240 | | `mode` | `object` | optional | Type values: `day/week/month`. Style values: `default/modern` Define the mode and style of the timeline. Default mode is `day` and style is `default` | `PRO` | 241 | | `overlap` | `object` | optional | Enable the element overlaps in the layout. Mode values: `stack/layer`, layerOverlapLevel: `number` | `PRO` | 242 | | `drag and drop` | `object` | optional | Drag and move the element in the layout. Mode values: `row/multi-rows` | `PRO` | 243 | | `grid layout` | `object` | optional | Background grid on the layout. Mode hoverHighlight values: `true/false`, onGridItemClick: function with all the properties on clicked item grid | `PRO` | 244 | | `channelMapKey` | `string` | optional | The Channel `uuid` attribute can be controlled by prop. Key map gives a possibilities to use specific prop from own data instead of needing to map to uuid in own data | `PRO` | 245 | | `programChannelMapKey` | `string` | optional | The Programs `channelUuid` attributes can be controlled by prop. Key map gives a possibilities to use a specific prop from own data instead of needing to map to channelUuid in your data | `PRO` | 246 | | `globalStyles` | `string` | optional | Inject custom global styles and font. Font weight: 400,500,600. Default font is "Inter" | `PRO` | 247 | 248 | #### Note about width and height props 249 | 250 | Without declaring the `width` and `length` properties, the component takes the dimensions of the parent element. 251 | 252 | #### globalStyles 253 | 254 | Inject own custom font and other global styles. 255 | 256 | ```tsx 257 | const globalStyles = ` 258 | @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"); 259 | 260 | /* Available in PRO plan */ 261 | .planby { 262 | font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, 263 | Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; 264 | 265 | /* Layout */ 266 | .planby-layout {} 267 | 268 | /* Line */ 269 | .planby-line {} 270 | 271 | /* Current time */ 272 | .planby-current-time {} 273 | .planby-current-content {} 274 | 275 | /* Channels */ 276 | .planby-channels {} 277 | 278 | /* Channel */ 279 | .planby-channel {} 280 | 281 | /* Program */ 282 | .planby-program {} 283 | .planby-program-content {} 284 | .planby-program-flex {} 285 | .planby-program-stack {} 286 | .planby-program-title {} 287 | .planby-program-text {} 288 | 289 | /* Timeline */ 290 | .planby-timeline-wrapper {} 291 | .planby-timeline-box {} 292 | .planby-timeline-time {} 293 | .planby-timeline-dividers {} 294 | .planby-timeline-wrapper {} 295 | } 296 | 297 | `; 298 | ``` 299 | 300 | #### Instance Properties 301 | 302 | Properties returned from useEpg 303 | 304 | | Property | Type | Description | 305 | | --------------- | ------------------------- | ------------------------------------ | 306 | | `scrollY` | `number` | Current scroll y value | 307 | | `scrollX` | `number` | Current scroll x value | 308 | | `onScrollLeft` | `function(value: number)` | Default value is 300 | 309 | | `onScrollRight` | `function(value: number)` | Default value is 300 | 310 | | `onScrollToNow` | `function()` | Scroll to current time/live programs | 311 | | `onScrollTop` | `function(value: number)` | Default value is 300 | 312 | 313 | ### Channel schema 314 | 315 | | Property | Type | Status | 316 | | -------- | -------- | -------- | 317 | | `logo` | `string` | required | 318 | | `uuid` | `string` | required | 319 | 320 | ### Epg schema 321 | 322 | | Property | Type | Status | Description | Access | 323 | | ----------------- | --------- | -------- | -------------------------------------------------------------------- | -------- | 324 | | `channelUuid` | `string` | required | 325 | | `id` | `string` | required | 326 | | `image` | `string` | required | 327 | | `since` | `string` | required | 328 | | `till` | `string` | required | 329 | | `title` | `string` | required | 330 | | `fixedVisibility` | `boolean` | optional | The element is always visible in the layout during the scroll events | Sponsors | 331 | 332 | ### Epg 333 | 334 | #### Base props 335 | 336 | Available props in Epg 337 | 338 | | Property | Type | Description | Status | 339 | | ----------- | ----------- | ----------------------- | -------- | 340 | | `isLoading` | `boolean` | Loader state | optional | 341 | | `loader` | `Component` | Loader custom component | optional | 342 | 343 | ### Layout 344 | 345 | #### Base props 346 | 347 | Available props in Layout. 348 | 349 | | Property | Type | Description | Status | Access | 350 | | ------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -------- | ---------- | 351 | | `renderProgram` | `function({ program: { data: object, position: object})` | `data` object contains all properties related to the program, `position` object includes all position styles | optional | 352 | | `renderChannel` | `function({ channel: { ..., position: object})` | `channel` object contains all properties related to the channel, `position` object includes all position styles | optional | 353 | | `renderTimeline` | `function({sidebarWidth: number})` | `sidebarWidth` value of the channel's sidebar width | optional | 354 | | `renderLine` | `function({styles: object})` | basic `styles` and `position` values for the custom live tracking Line | optional | `Sponsors` | 355 | | `renderCurrentTime` | `function({styles: object, isRTL: boolean, isBaseTimeFormat: boolean, time: string})` | basic `styles` values for the custom current time | optional | `Sponsors` | 356 | 357 | # Render functions 358 | 359 | You can use Plaby's style components to develop main features. Moreover, you can integrate with third party UI library eg. Chakra UI, Material UI etc or make custom styles. 360 | 361 | ## renderProgram 362 | 363 | Below is an example that allows you to render your custom Program component using Plaby's style components. 364 | 365 | ```tsx 366 | import { 367 | useEpg, 368 | Epg, 369 | Layout, 370 | ProgramBox, 371 | ProgramContent, 372 | ProgramFlex, 373 | ProgramStack, 374 | ProgramTitle, 375 | ProgramText, 376 | ProgramImage, 377 | useProgram, 378 | Program, 379 | ProgramItem 380 | } from "planby"; 381 | 382 | 383 | const Item = ({ program,...rest }: ProgramItem) => { 384 | const { styles, formatTime, isLive, isMinWidth } = useProgram({ program,...rest }); 385 | 386 | const { data } = program; 387 | const { image, title, since, till } = data; 388 | 389 | const sinceTime = formatTime(since); 390 | const tillTime = formatTime(till); 391 | 392 | return ( 393 | 394 | 398 | 399 | {isLive && isMinWidth && } 400 | 401 | {title} 402 | 403 | {sinceTime} - {tillTime} 404 | 405 | 406 | 407 | 408 | 409 | ); 410 | }; 411 | 412 | function App() { 413 | 414 | ... 415 | 416 | const { 417 | getEpgProps, 418 | getLayoutProps, 419 | } = useEpg({ 420 | epg, 421 | channels, 422 | startDate: '2022/02/02', // or 2022-02-02T00:00:00 423 | }); 424 | 425 | return ( 426 |
427 |
428 | 429 | ( 432 | 433 | )} 434 | /> 435 | 436 |
437 |
438 | ); 439 | } 440 | 441 | export default App; 442 | ``` 443 | 444 | ## renderProgram - 12 hours time format 445 | 446 | Below is an example that allows you to render your custom Program component with 12 hours time format using Plaby's style components. 447 | 448 | ```tsx 449 | ... 450 | const Item = ({ program, ...rest }: ProgramItem) => { 451 | const { 452 | styles, 453 | formatTime, 454 | set12HoursTimeFormat, 455 | isLive, 456 | isMinWidth, 457 | } = useProgram({ 458 | program, 459 | ...rest 460 | }); 461 | 462 | const { data } = program; 463 | const { image, title, since, till } = data; 464 | 465 | const sinceTime = formatTime(since, set12HoursTimeFormat()).toLowerCase(); 466 | const tillTime = formatTime(till, set12HoursTimeFormat()).toLowerCase(); 467 | 468 | return ( 469 | 470 | 474 | 475 | {isLive && isMinWidth && } 476 | 477 | {title} 478 | 479 | {sinceTime} - {tillTime} 480 | 481 | 482 | 483 | 484 | 485 | ); 486 | }; 487 | 488 | function App() { 489 | 490 | ... 491 | 492 | const { 493 | getEpgProps, 494 | getLayoutProps, 495 | } = useEpg({ 496 | epg, 497 | channels, 498 | isBaseTimeFormat: true, 499 | startDate: '2022/02/02', // or 2022-02-02T00:00:00 500 | }); 501 | 502 | ... 503 | } 504 | 505 | export default App; 506 | ``` 507 | 508 | ## renderProgram - RTL direction 509 | 510 | Below is an example that allows you to render your custom Program component with RTL direction using Plaby's style components. 511 | 512 | ```tsx 513 | ... 514 | const Item = ({ program, ...rest }: ProgramItem) => { 515 | const { 516 | isRTL, 517 | isLive, 518 | isMinWidth, 519 | formatTime, 520 | styles, 521 | set12HoursTimeFormat, 522 | getRTLSinceTime, 523 | getRTLTillTime, 524 | } = useProgram({ 525 | program, 526 | ...rest 527 | }); 528 | 529 | const { data } = program; 530 | const { image, title, since, till } = data; 531 | 532 | const sinceTime = formatTime( 533 | getRTLSinceTime(since), 534 | set12HoursTimeFormat() 535 | ).toLowerCase(); 536 | const tillTime = formatTime( 537 | getRTLTillTime(till), 538 | set12HoursTimeFormat() 539 | ).toLowerCase(); 540 | 541 | return ( 542 | 543 | 544 | 545 | {isLive && isMinWidth && } 546 | 547 | {title} 548 | 549 | {sinceTime} - {tillTime} 550 | 551 | 552 | 553 | 554 | 555 | ); 556 | }; 557 | 558 | function App() { 559 | 560 | ... 561 | 562 | const { 563 | getEpgProps, 564 | getLayoutProps, 565 | } = useEpg({ 566 | epg, 567 | channels, 568 | isBaseTimeFormat: true, 569 | startDate: '2022/02/02', // or 2022-02-02T00:00:00 570 | }); 571 | 572 | ... 573 | } 574 | 575 | export default App; 576 | ``` 577 | 578 | ## renderChannel 579 | 580 | Below is an example that allows you to render your custom Channel component using Plaby's style components. 581 | 582 | ```tsx 583 | import { useEpg, Epg, Layout, ChannelBox, ChannelLogo, Channel } from 'planby'; 584 | 585 | interface ChannelItemProps { 586 | channel: Channel; 587 | } 588 | 589 | const ChannelItem = ({ channel }: ChannelItemProps) => { 590 | const { position, logo } = channel; 591 | return ( 592 | 593 | console.log('channel', channel)} 595 | src={logo} 596 | alt="Logo" 597 | /> 598 | 599 | ); 600 | }; 601 | 602 | 603 | function App() { 604 | 605 | ... 606 | 607 | const { 608 | getEpgProps, 609 | getLayoutProps, 610 | } = useEpg({ 611 | epg, 612 | channels, 613 | startDate: '2022/02/02', // or 2022-02-02T00:00:00 614 | }); 615 | 616 | return ( 617 |
618 |
619 | 620 | ( 623 | 624 | )} 625 | /> 626 | 627 |
628 |
629 | ); 630 | } 631 | 632 | ``` 633 | 634 | ## renderTimeline 635 | 636 | Below is an example that allows you to render your custom Timeline component using Plaby's style components. 637 | 638 | ```tsx 639 | import { 640 | TimelineWrapper, 641 | TimelineBox, 642 | TimelineTime, 643 | TimelineDivider, 644 | TimelineDividers, 645 | useTimeline, 646 | } from 'planby'; 647 | 648 | interface TimelineProps { 649 | isBaseTimeFormat: boolean; 650 | isSidebar: boolean; 651 | dayWidth: number; 652 | hourWidth: number; 653 | numberOfHoursInDay: number; 654 | offsetStartHoursRange: number; 655 | sidebarWidth: number; 656 | } 657 | 658 | export function Timeline({ 659 | isBaseTimeFormat, 660 | isSidebar, 661 | dayWidth, 662 | hourWidth, 663 | numberOfHoursInDay, 664 | offsetStartHoursRange, 665 | sidebarWidth, 666 | }: TimelineProps) { 667 | const { time, dividers, formatTime } = useTimeline( 668 | numberOfHoursInDay, 669 | isBaseTimeFormat 670 | ); 671 | 672 | const renderTime = (index: number) => ( 673 | 674 | 675 | {formatTime(index + offsetStartHoursRange).toLowerCase()} 676 | 677 | {renderDividers()} 678 | 679 | ); 680 | 681 | const renderDividers = () => 682 | dividers.map((_, index) => ( 683 | 684 | )); 685 | 686 | return ( 687 | 692 | {time.map((_, index) => renderTime(index))} 693 | 694 | ); 695 | } 696 | 697 | function App() { 698 | 699 | ... 700 | 701 | const { 702 | getEpgProps, 703 | getLayoutProps, 704 | } = useEpg({ 705 | epg, 706 | channels, 707 | startDate: '2022/02/02', // or 2022-02-02T00:00:00 708 | }); 709 | 710 | return ( 711 |
712 |
713 | 714 | } 717 | /> 718 | 719 |
720 |
721 | ); 722 | } 723 | 724 | export default App; 725 | ``` 726 | 727 | ## renderTimeline - RTL direction 728 | 729 | Below is an example that allows you to render your custom Timeline component using Plaby's style components. 730 | 731 | ```tsx 732 | import { 733 | TimelineWrapper, 734 | TimelineBox, 735 | TimelineTime, 736 | TimelineDivider, 737 | TimelineDividers, 738 | useTimeline, 739 | } from 'planby'; 740 | 741 | interface TimelineProps { 742 | isRTL: boolean; 743 | isBaseTimeFormat: boolean; 744 | isSidebar: boolean; 745 | dayWidth: number; 746 | hourWidth: number; 747 | numberOfHoursInDay: number; 748 | offsetStartHoursRange: number; 749 | sidebarWidth: number; 750 | } 751 | 752 | export function Timeline({ 753 | isRTL, 754 | isBaseTimeFormat, 755 | isSidebar, 756 | dayWidth, 757 | hourWidth, 758 | numberOfHoursInDay, 759 | offsetStartHoursRange, 760 | sidebarWidth, 761 | }: TimelineProps) { 762 | const { time, dividers, formatTime } = useTimeline( 763 | numberOfHoursInDay, 764 | isBaseTimeFormat 765 | ); 766 | 767 | const renderTime = (index: number) => ( 768 | 769 | 770 | {formatTime(index + offsetStartHoursRange).toLowerCase()} 771 | 772 | {renderDividers()} 773 | 774 | ); 775 | 776 | ... 777 | } 778 | 779 | ``` 780 | 781 | ## Theme 782 | 783 | ### Schema 784 | 785 | Make your theme custom. Below is theme schema that you can pass as one of the options to `useEpg` hook. 786 | 787 | ```jsx 788 | const theme = { 789 | primary: { 790 | 600: '#1a202c', 791 | 900: '#171923', 792 | }, 793 | grey: { 300: '#d1d1d1' }, 794 | white: '#fff', 795 | green: { 796 | 300: '#2C7A7B', 797 | }, 798 | loader: { 799 | teal: '#5DDADB', 800 | purple: '#3437A2', 801 | pink: '#F78EB6', 802 | bg: '#171923db', 803 | }, 804 | scrollbar: { 805 | border: '#ffffff', 806 | thumb: { 807 | bg: '#e1e1e1', 808 | }, 809 | }, 810 | gradient: { 811 | blue: { 812 | 300: '#002eb3', 813 | 600: '#002360', 814 | 900: '#051937', 815 | }, 816 | }, 817 | text: { 818 | grey: { 819 | 300: '#a0aec0', 820 | 500: '#718096', 821 | }, 822 | }, 823 | timeline: { 824 | divider: { 825 | bg: '#718096', 826 | }, 827 | }, 828 | }; 829 | ``` 830 | 831 | ## All import options 832 | 833 | ```tsx 834 | import { 835 | Epg, 836 | Layout, 837 | ChannelBox, 838 | ChannelLogo, 839 | ProgramBox, 840 | ProgramContent, 841 | ProgramFlex, 842 | ProgramStack, 843 | ProgramTitle, 844 | ProgramText, 845 | ProgramImage, 846 | TimelineWrapper, 847 | TimelineBox, 848 | TimelineTime, 849 | TimelineDividers, 850 | useEpg, 851 | useProgram, 852 | useTimeline, 853 | Program, // Interface 854 | Channel, // Interface 855 | ProgramItem, // Interface for program render 856 | Theme, // Interface 857 | } from 'planby'; 858 | ``` 859 | 860 | ## License 861 | 862 | Custom License - All Rights Reserved. [See `LICENSE` for more information](https://planby.app/docs/planby-license.pdf). 863 | 864 | ## Contact 865 | 866 | Karol Kozer - [@kozerkarol_twitter](https://twitter.com/kozerkarol) 867 | 868 | Project Link: [https://github.com/karolkozer/planby](https://github.com/karolkozer/planby) 869 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "planby", 3 | "author": "Karol Kozer", 4 | "version": "2.0.0", 5 | "license": "Custom License", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/karolkozer/planby" 9 | }, 10 | "homepage": "https://planby.app", 11 | "funding": { 12 | "type": "opencollective", 13 | "url": "https://opencollective.com/planby" 14 | }, 15 | "main": "dist/index.js", 16 | "typings": "dist/index.d.ts", 17 | "files": [ 18 | "dist" 19 | ], 20 | "engines": { 21 | "node": ">=10" 22 | }, 23 | "scripts": { 24 | "start": "tsdx watch", 25 | "build": "tsdx build", 26 | "test": "tsdx test --passWithNoTests", 27 | "lint": "tsdx lint", 28 | "prepare": "tsdx build && bundlewatch", 29 | "size": "bundlewatch", 30 | "test:watch": "jest --watchAll" 31 | }, 32 | "peerDependencies": { 33 | "react": ">=19" 34 | }, 35 | "husky": { 36 | "hooks": { 37 | "pre-commit": "tsdx lint" 38 | } 39 | }, 40 | "prettier": { 41 | "printWidth": 80, 42 | "semi": true, 43 | "singleQuote": true, 44 | "trailingComma": "es5" 45 | }, 46 | "module": "dist/planby.esm.js", 47 | "bundlewatch": { 48 | "files": [ 49 | { 50 | "path": "dist/planby.cjs.production.min.js", 51 | "maxSize": "30kB" 52 | }, 53 | { 54 | "path": "dist/planby.esm.js", 55 | "maxSize": "30kB" 56 | } 57 | ] 58 | }, 59 | "dependencies": { 60 | "@emotion/react": "^11.9.0", 61 | "@emotion/styled": "^11.8.1", 62 | "date-fns": "^2.28.0", 63 | "use-debounce": "^7.0.1" 64 | }, 65 | "keywords": [ 66 | "epg", 67 | "schedule", 68 | "harmongram", 69 | "react", 70 | "hooks", 71 | "electronic", 72 | "program", 73 | "guide", 74 | "timeline", 75 | "events" 76 | ], 77 | "devDependencies": { 78 | "@faker-js/faker": "^6.1.2", 79 | "@size-limit/preset-small-lib": "^7.0.8", 80 | "@types/jest": "^27.4.0", 81 | "@types/react": "^17.0.38", 82 | "bundlewatch": "^0.3.3", 83 | "husky": "^7.0.4", 84 | "jest": "^27.5.1", 85 | "size-limit": "^7.0.8", 86 | "ts-jest": "^27.1.3", 87 | "tsdx": "^0.14.1", 88 | "tslib": "^2.3.1", 89 | "typescript": "^4.6.3" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Epg/Epg.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ThemeProvider, Global } from "@emotion/react"; 3 | 4 | // Import interfaces 5 | import { Theme } from "./helpers/interfaces"; 6 | 7 | // Import helpers 8 | import { TIMELINE_HEIGHT } from "./helpers"; 9 | 10 | // Import styles 11 | import { globalStyles, EpgStyled } from "./styles"; 12 | 13 | // Import components 14 | import { Loader } from "./components"; 15 | 16 | interface EpgProps { 17 | ref: React.RefObject; 18 | width?: number; 19 | height?: number; 20 | isRTL?: boolean; 21 | isSidebar: boolean; 22 | isTimeline?: boolean; 23 | isLoading?: boolean; 24 | children: React.ReactNode; 25 | loader?: React.ReactNode; 26 | theme: Theme; 27 | globalStyles?: string; 28 | sidebarWidth: number; 29 | } 30 | 31 | const { Container, Wrapper, Box } = EpgStyled; 32 | 33 | export const Epg = ({ 34 | children, 35 | width, 36 | height, 37 | sidebarWidth, 38 | theme, 39 | globalStyles: customGlobalStyles, 40 | isRTL = false, 41 | isSidebar = true, 42 | isTimeline = true, 43 | isLoading = false, 44 | loader: LoaderComponent, 45 | ref: containerRef, 46 | ...rest 47 | }: EpgProps) => { 48 | const renderLoader = () => LoaderComponent ?? ; 49 | const epgGlobalStyles = customGlobalStyles ?? globalStyles; 50 | return ( 51 | 52 | 53 | 61 | 62 | {isSidebar && isTimeline && ( 63 | 70 | )} 71 | {isLoading && renderLoader()} 72 | {children} 73 | 74 | 75 | 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /src/Epg/__tests__/Epg.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen, within } from "../test/test-utils"; 3 | import { Epg } from "../Epg"; 4 | import { Layout } from "../components"; 5 | 6 | import { getLayoutProps, getTestTimeDate } from "../test"; 7 | 8 | // Import theme 9 | import { theme as defaultTheme } from "../theme"; 10 | 11 | type Overrides = { [key: string]: any }; 12 | function getEpgProps(overrides: Overrides = {}) { 13 | return { 14 | theme: defaultTheme, 15 | sidebarWidth: 100, 16 | ...overrides, 17 | }; 18 | } 19 | 20 | interface RenderEpg { 21 | epgOptions?: Overrides; 22 | layoutOptions?: Overrides; 23 | sliceNumber?: number; 24 | } 25 | function renderEpg({ epgOptions, layoutOptions, sliceNumber }: RenderEpg = {}) { 26 | const layoutProps = getLayoutProps(layoutOptions, sliceNumber); 27 | render( 28 | <> 29 | {/* @ts-ignore:next-line */} 30 | 31 | 32 | 33 | 34 | ); 35 | return { layoutProps }; 36 | } 37 | 38 | test("should render Epg coponent properly", () => { 39 | const props = getEpgProps(); 40 | const layoutProps = getLayoutProps(); 41 | render( 42 |
43 | {/* @ts-ignore:next-line */} 44 | 45 | 46 | 47 |
48 | ); 49 | 50 | const container = screen.getByTestId("container"); 51 | 52 | expect(container).toBeInTheDocument(); 53 | expect(container).toHaveStyle(`height: 100%; width: 100%`); 54 | 55 | const timeline = screen.getByTestId("timeline"); 56 | const inTimeline = within(timeline); 57 | expect(timeline).toBeInTheDocument(); 58 | expect(inTimeline.getByText("00:00")).toBeInTheDocument(); 59 | expect(inTimeline.getAllByTestId("timeline-item")).toHaveLength(24); 60 | 61 | const sidebar = screen.getByTestId("sidebar"); 62 | const inSidebar = within(sidebar); 63 | expect(sidebar).toBeInTheDocument(); 64 | expect(inSidebar.getAllByTestId("sidebar-item")).toHaveLength(1); 65 | expect(inSidebar.getByRole("img", { name: /logo/i })).toHaveAttribute( 66 | "src", 67 | layoutProps.channels[0].logo 68 | ); 69 | 70 | const content = screen.getByTestId("content"); 71 | const inContent = within(content); 72 | expect(content).toBeInTheDocument(); 73 | expect(inContent.getAllByTestId("program-item")).toHaveLength(1); 74 | 75 | const firstProgram = inContent.getAllByTestId("program-item")[0]; 76 | expect(firstProgram).toHaveTextContent(layoutProps.programs[0].data.title); 77 | expect(firstProgram).toHaveStyle( 78 | `top: ${layoutProps.programs[0].position.top}px` 79 | ); 80 | expect(firstProgram).toHaveStyle( 81 | `width: ${layoutProps.programs[0].position.width}px` 82 | ); 83 | }); 84 | 85 | test("should set initial Epg props", () => { 86 | const epgOptions = getEpgProps({ width: 1000, height: 600 }); 87 | const layoutOptions = { 88 | dayWidth: 7200, 89 | startDate: getTestTimeDate("01"), 90 | endDate: getTestTimeDate("23"), 91 | hourWidth: 300, 92 | isBaseTimeFormat: true, 93 | numberOfHoursInDay: 22, 94 | offsetStartHoursRange: 1, 95 | }; 96 | renderEpg({ 97 | epgOptions, 98 | layoutOptions, 99 | sliceNumber: 10, 100 | }); 101 | 102 | const container = screen.getByTestId("container"); 103 | expect(container).toBeInTheDocument(); 104 | expect(container).toHaveStyle(`height: 600px; width: 1000px`); 105 | 106 | const timeline = screen.getByTestId("timeline"); 107 | const inTimeline = within(timeline); 108 | expect(timeline).toBeInTheDocument(); 109 | expect(inTimeline.queryByText("00:00")).not.toBeInTheDocument(); 110 | expect(inTimeline.getByText("1:00am")).toBeInTheDocument(); 111 | expect(inTimeline.getAllByTestId("timeline-item")).toHaveLength(22); 112 | expect(inTimeline.getAllByTestId("timeline-item")[0]).toHaveStyle( 113 | `width: ${layoutOptions.hourWidth}px` 114 | ); 115 | 116 | const content = screen.getByTestId("content"); 117 | expect(content).toHaveStyle(`width: ${layoutOptions.dayWidth}px`); 118 | }); 119 | 120 | test("should show loader in Epg layout", () => { 121 | const epgOptions = getEpgProps({ isLoading: true }); 122 | renderEpg({ epgOptions }); 123 | 124 | expect(screen.getByLabelText(/loading/i)).toBeInTheDocument(); 125 | }); 126 | -------------------------------------------------------------------------------- /src/Epg/components/Channel.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | // Import interfaces 4 | import { ChannelWithPosition } from "../helpers/types"; 5 | 6 | // Import styles 7 | import { ChannelStyled } from "../styles"; 8 | 9 | interface ChannelProps { 10 | channel: T; 11 | onClick?: (v: ChannelWithPosition) => void; 12 | } 13 | 14 | const { ChannelBox, ChannelLogo } = ChannelStyled; 15 | 16 | export function Channel({ 17 | channel, 18 | onClick, 19 | ...rest 20 | }: ChannelProps) { 21 | const { position, logo } = channel; 22 | return ( 23 | onClick?.(channel)} 26 | {...position} 27 | {...rest} 28 | > 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/Epg/components/Channels.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | // Import interfaces 3 | import { ChannelWithPosition } from "../helpers/types"; 4 | 5 | // Import styles 6 | import { ChannelsStyled } from "../styles"; 7 | 8 | // Import Components 9 | import { Channel } from "../components"; 10 | 11 | interface ChannelsProps { 12 | isTimeline: boolean; 13 | isRTL: boolean; 14 | isChannelVisible: (position: any) => boolean; 15 | channels: ChannelWithPosition[]; 16 | scrollY: number; 17 | sidebarWidth: number; 18 | renderChannel?: (v: { channel: ChannelWithPosition }) => React.ReactNode; 19 | } 20 | 21 | const { Box } = ChannelsStyled; 22 | 23 | export function Channels(props: ChannelsProps) { 24 | const { channels, scrollY, sidebarWidth, renderChannel } = props; 25 | const { isRTL, isTimeline, isChannelVisible } = props; 26 | 27 | const renderChannels = (channel: ChannelWithPosition) => { 28 | const isVisible = isChannelVisible(channel.position); 29 | if (isVisible) { 30 | if (renderChannel) return renderChannel({ channel }); 31 | return ; 32 | } 33 | return null; 34 | }; 35 | 36 | return ( 37 | 44 | {channels.map(renderChannels)} 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/Epg/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // Import types 4 | import { 5 | ProgramItem, 6 | ProgramWithPosition, 7 | ChannelWithPosition, 8 | DateTime, 9 | Position, 10 | BaseTimeFormat, 11 | } from "../helpers/types"; 12 | 13 | // Import helpers 14 | import { getProgramOptions, isFutureTime } from "../helpers"; 15 | 16 | // Import styles 17 | import { EpgStyled } from "../styles"; 18 | 19 | // Import components 20 | import { Timeline, Channels, Program, Line } from "../components"; 21 | 22 | interface RenderTimeline { 23 | isBaseTimeFormat: BaseTimeFormat; 24 | isSidebar: boolean; 25 | isRTL: boolean; 26 | sidebarWidth: number; 27 | hourWidth: number; 28 | numberOfHoursInDay: number; 29 | offsetStartHoursRange: number; 30 | dayWidth: number; 31 | } 32 | 33 | interface LayoutProps { 34 | programs: ProgramItem[]; 35 | channels: ChannelWithPosition[]; 36 | startDate: DateTime; 37 | endDate: DateTime; 38 | scrollY: number; 39 | dayWidth: number; 40 | hourWidth: number; 41 | numberOfHoursInDay: number; 42 | offsetStartHoursRange: number; 43 | sidebarWidth: number; 44 | itemHeight: number; 45 | ref: React.RefObject; 46 | onScroll: ( 47 | e: React.UIEvent & { target: Element } 48 | ) => void; 49 | isRTL?: boolean; 50 | isBaseTimeFormat?: BaseTimeFormat; 51 | isSidebar?: boolean; 52 | isTimeline?: boolean; 53 | isLine?: boolean; 54 | isProgramVisible: (position: Position) => boolean; 55 | isChannelVisible: (position: Pick) => boolean; 56 | renderProgram?: (v: { 57 | program: ProgramItem; 58 | isRTL: boolean; 59 | isBaseTimeFormat: BaseTimeFormat; 60 | }) => React.ReactNode; 61 | renderChannel?: (v: { channel: ChannelWithPosition }) => React.ReactNode; 62 | renderTimeline?: (v: RenderTimeline) => React.ReactNode; 63 | } 64 | 65 | const { ScrollBox, Content } = EpgStyled; 66 | 67 | export const Layout = 68 | ({ ref: scrollBoxRef, ...props }: LayoutProps) => { 69 | const { channels, programs, startDate, endDate, scrollY } = props; 70 | const { dayWidth, hourWidth, sidebarWidth, itemHeight } = props; 71 | const { numberOfHoursInDay, offsetStartHoursRange } = props; 72 | const { 73 | isSidebar = true, 74 | isTimeline = true, 75 | isLine = true, 76 | isBaseTimeFormat = false, 77 | isRTL = false, 78 | } = props; 79 | 80 | const { 81 | onScroll, 82 | isProgramVisible, 83 | isChannelVisible, 84 | renderProgram, 85 | renderChannel, 86 | renderTimeline, 87 | } = props; 88 | 89 | const channelsLength = channels.length; 90 | const contentHeight = React.useMemo(() => channelsLength * itemHeight, [ 91 | channelsLength, 92 | itemHeight, 93 | ]); 94 | const isFuture = isFutureTime(endDate); 95 | 96 | const renderPrograms = (program: ProgramWithPosition) => { 97 | const { position } = program; 98 | const isVisible = isProgramVisible(position); 99 | 100 | if (isVisible) { 101 | const options = getProgramOptions(program); 102 | if (renderProgram) 103 | return renderProgram({ 104 | program: options, 105 | isRTL, 106 | isBaseTimeFormat, 107 | }); 108 | return ( 109 | 115 | ); 116 | } 117 | return null; 118 | }; 119 | 120 | const renderTopbar = () => { 121 | const props = { 122 | sidebarWidth, 123 | isSidebar, 124 | isRTL, 125 | dayWidth, 126 | numberOfHoursInDay, 127 | }; 128 | const timeProps = { 129 | offsetStartHoursRange, 130 | numberOfHoursInDay, 131 | isBaseTimeFormat, 132 | hourWidth, 133 | }; 134 | if (renderTimeline) { 135 | return renderTimeline({ ...timeProps, ...props }); 136 | } 137 | return ; 138 | }; 139 | 140 | return ( 141 | 142 | {isLine && isFuture && ( 143 | 151 | )} 152 | {isTimeline && renderTopbar()} 153 | {isSidebar && ( 154 | 163 | )} 164 | 171 | {programs.map((program) => 172 | renderPrograms(program as ProgramWithPosition) 173 | )} 174 | 175 | 176 | ); 177 | } 178 | -------------------------------------------------------------------------------- /src/Epg/components/Line/Line.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { isToday } from "date-fns"; 3 | 4 | // Import types 5 | import { DateTime } from "../../helpers/types"; 6 | 7 | // Import styles 8 | import { LineStyled } from "../../styles"; 9 | 10 | // Import components 11 | import { useLine } from "../../hooks/useLine"; 12 | 13 | interface LineProps { 14 | height: number; 15 | startDate: DateTime; 16 | endDate: DateTime; 17 | dayWidth: number; 18 | hourWidth: number; 19 | sidebarWidth: number; 20 | } 21 | 22 | const { Box } = LineStyled; 23 | 24 | export function Line({ 25 | height, 26 | startDate, 27 | endDate, 28 | dayWidth, 29 | hourWidth, 30 | sidebarWidth, 31 | }: LineProps) { 32 | const { positionX } = useLine({ 33 | startDate, 34 | endDate, 35 | dayWidth, 36 | hourWidth, 37 | sidebarWidth, 38 | }); 39 | 40 | const date = new Date(startDate); 41 | if (!isToday(date)) return null; 42 | 43 | return ; 44 | } 45 | -------------------------------------------------------------------------------- /src/Epg/components/Line/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Line"; 2 | -------------------------------------------------------------------------------- /src/Epg/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { theme } from "../theme/theme"; 3 | 4 | // Import styles 5 | import { LoaderStyled } from "../styles"; 6 | 7 | const { Box, Shape } = LoaderStyled; 8 | 9 | const Element = ({ 10 | width, 11 | color, 12 | animate, 13 | marginRight, 14 | transition, 15 | }: { 16 | width: number; 17 | color: string; 18 | transition: { duration: number; ease?: string; delay?: number }; 19 | animate: { right: string[] }; 20 | marginRight?: number; 21 | }) => ( 22 | 30 | ); 31 | 32 | export function Loader() { 33 | return ( 34 | 35 |
36 |
39 | 50 | 61 |
62 |
65 | 76 |
77 |
78 | 90 | 101 |
102 |
103 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /src/Epg/components/Program.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | // Import interfaces 4 | import { Program as ProgramType } from "../helpers/interfaces"; 5 | import { BaseTimeFormat } from "../helpers/types"; 6 | 7 | // Import types 8 | import { ProgramItem } from "../helpers/types"; 9 | 10 | // Import styles 11 | import { ProgramStyled } from "../styles"; 12 | 13 | // Import hooks 14 | import { useProgram } from "../hooks"; 15 | 16 | interface ProgramProps { 17 | isRTL?: boolean; 18 | isBaseTimeFormat: BaseTimeFormat; 19 | program: T; 20 | onClick?: (v: ProgramType) => void; 21 | } 22 | 23 | const { 24 | ProgramBox, 25 | ProgramContent, 26 | ProgramFlex, 27 | ProgramStack, 28 | ProgramTitle, 29 | ProgramText, 30 | ProgramImage, 31 | } = ProgramStyled; 32 | 33 | export function Program({ 34 | program, 35 | onClick, 36 | ...rest 37 | }: ProgramProps) { 38 | const { 39 | isRTL, 40 | isLive, 41 | isMinWidth, 42 | styles, 43 | formatTime, 44 | set12HoursTimeFormat, 45 | getRTLSinceTime, 46 | getRTLTillTime, 47 | } = useProgram({ 48 | program, 49 | ...rest, 50 | }); 51 | 52 | const { data } = program; 53 | const { image, title, since, till } = data; 54 | 55 | const handleOnContentClick = () => onClick?.(data); 56 | 57 | const sinceTime = formatTime( 58 | getRTLSinceTime(since), 59 | set12HoursTimeFormat() 60 | ).toLowerCase(); 61 | const tillTime = formatTime( 62 | getRTLTillTime(till), 63 | set12HoursTimeFormat() 64 | ).toLowerCase(); 65 | 66 | return ( 67 | 72 | 79 | 80 | {isLive && isMinWidth && } 81 | 82 | {title} 83 | 84 | {sinceTime} - {tillTime} 85 | 86 | 87 | 88 | 89 | 90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /src/Epg/components/Timeline.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | // Import types 4 | import { BaseTimeFormat } from "../helpers/types"; 5 | 6 | // Import styles 7 | import { TimelineStyled } from "../styles"; 8 | 9 | // Import hooks 10 | import { useTimeline } from "../hooks"; 11 | 12 | const { 13 | TimelineWrapper, 14 | TimelineBox, 15 | TimelineTime, 16 | TimelineDividers, 17 | TimelineDivider, 18 | } = TimelineStyled; 19 | 20 | interface TimelineProps { 21 | isRTL?: boolean; 22 | isBaseTimeFormat: BaseTimeFormat; 23 | isSidebar: boolean; 24 | dayWidth: number; 25 | hourWidth: number; 26 | numberOfHoursInDay: number; 27 | offsetStartHoursRange: number; 28 | sidebarWidth: number; 29 | } 30 | 31 | export function Timeline({ 32 | isRTL, 33 | isBaseTimeFormat, 34 | isSidebar, 35 | dayWidth, 36 | hourWidth, 37 | numberOfHoursInDay, 38 | offsetStartHoursRange, 39 | sidebarWidth, 40 | }: TimelineProps) { 41 | const { time, dividers, formatTime } = useTimeline( 42 | numberOfHoursInDay, 43 | isBaseTimeFormat 44 | ); 45 | 46 | const renderTime = (index: number) => ( 47 | 48 | 49 | {formatTime(index + offsetStartHoursRange)} 50 | 51 | {renderDividers()} 52 | 53 | ); 54 | 55 | const renderDividers = () => 56 | dividers.map((_, index) => ( 57 | 58 | )); 59 | 60 | return ( 61 | 67 | {time.map((_, index) => renderTime(index))} 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/Epg/components/__tests__/Channel.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "../../test/test-utils"; 2 | import { Channel } from "../Channel"; 3 | import { buildChannelWithPosition } from "../../test"; 4 | 5 | test("should render and show Channel component properly", () => { 6 | const channel = buildChannelWithPosition(); 7 | render(); 8 | expect(screen.getByRole("img", { name: /logo/i })).toBeInTheDocument(); 9 | expect(screen.getByRole("img", { name: /logo/i })).toHaveAttribute( 10 | "src", 11 | channel.logo 12 | ); 13 | }); 14 | -------------------------------------------------------------------------------- /src/Epg/components/__tests__/Layout.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "../../test/test-utils"; 2 | import { Layout } from "../Layout"; 3 | import { getLayoutProps } from "../../test"; 4 | 5 | test("should render Layout component properly", () => { 6 | const props = getLayoutProps(); 7 | 8 | render(); 9 | 10 | const timeline = screen.getByTestId("timeline"); 11 | expect(timeline).toBeInTheDocument(); 12 | 13 | const sidebar = screen.getByTestId("sidebar"); 14 | expect(sidebar).toBeInTheDocument(); 15 | 16 | const content = screen.getByTestId("content"); 17 | expect(content).toBeInTheDocument(); 18 | }); 19 | 20 | test("should render Layout with hidden timeline", () => { 21 | const props = getLayoutProps({ isTimeline: false }); 22 | 23 | render(); 24 | 25 | const timeline = screen.queryByTestId("timeline"); 26 | expect(timeline).not.toBeInTheDocument(); 27 | }); 28 | 29 | test("should render Layout with hidden sidebar", () => { 30 | const props = getLayoutProps({ isSidebar: false }); 31 | 32 | render(); 33 | 34 | const sideba = screen.queryByTestId("sideba"); 35 | expect(sideba).not.toBeInTheDocument(); 36 | }); 37 | 38 | test("should pass initial Layout props with sidebar width", () => { 39 | const sidebarWidth = 200; 40 | const props = getLayoutProps({ sidebarWidth }); 41 | 42 | render(); 43 | 44 | expect(screen.getByTestId("sidebar")).toHaveStyle(`width: ${sidebarWidth}px`); 45 | }); 46 | -------------------------------------------------------------------------------- /src/Epg/components/__tests__/Program.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, userEvent } from "../../test/test-utils"; 2 | import { Program } from "../Program"; 3 | import { buildProgramWithPosition } from "../../test"; 4 | import { subMinutes, addMinutes } from "date-fns"; 5 | 6 | test("should render and show Program component properly", () => { 7 | const program = buildProgramWithPosition(); 8 | render(); 9 | 10 | expect(screen.getByText(program.data.title)).toBeInTheDocument(); 11 | expect(screen.getByLabelText(/program time/i)).toBeInTheDocument(); 12 | expect(screen.getByLabelText(/program time/i)).toHaveTextContent( 13 | `23:50 - 00:55` 14 | ); 15 | }); 16 | 17 | test("should highlight live program", () => { 18 | const since = subMinutes(new Date(), 60); 19 | const till = addMinutes(new Date(), 60); 20 | const program = buildProgramWithPosition({ program: { since, till } }); 21 | 22 | render(); 23 | 24 | expect(screen.getByRole("img", { name: /preview/i })).toBeInTheDocument(); 25 | expect(screen.getByRole("img", { name: /preview/i })).toHaveAttribute( 26 | "src", 27 | program.data.image 28 | ); 29 | expect(screen.getByTestId(/program-content/i)).toHaveStyle( 30 | `background: linear-gradient(to right, #051937, #002360,#002eb3)` 31 | ); 32 | }); 33 | 34 | test("should handle onClick prop", () => { 35 | const onClick = jest.fn(); 36 | const program = buildProgramWithPosition(); 37 | 38 | render( 39 | 40 | ); 41 | 42 | userEvent.click(screen.getByTestId(/program-content/i)); 43 | 44 | expect(onClick).toHaveBeenCalled(); 45 | expect(onClick).toHaveBeenCalledTimes(1); 46 | }); 47 | -------------------------------------------------------------------------------- /src/Epg/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Layout"; 2 | export * from "./Program"; 3 | export * from "./Channels"; 4 | export * from "./Channel"; 5 | export * from "./Timeline"; 6 | export * from "./Line"; 7 | export * from "./Loader"; 8 | -------------------------------------------------------------------------------- /src/Epg/helpers/__tests__/common.test.ts: -------------------------------------------------------------------------------- 1 | import faker from "@faker-js/faker"; 2 | import { omit, getProgramOptions } from "../common"; 3 | import { buildChannel, buildProgramWithPosition } from "../../test"; 4 | 5 | function filterData( 6 | data: Record, 7 | name: string 8 | ) { 9 | return Object.entries(data).reduce((acc, [key, value]) => { 10 | if (key !== name) { 11 | acc[key as string] = value; 12 | } 13 | return acc; 14 | }, {} as Record); 15 | } 16 | 17 | describe("Common helpers", () => { 18 | it("should omit the keys", () => { 19 | const channel = buildChannel(); 20 | const channelWithoutLogo = filterData(channel, "logo"); 21 | expect(omit(channel, "logo")).toEqual(channelWithoutLogo); 22 | expect(omit(channel, "logo")).not.toEqual(channel); 23 | }); 24 | 25 | it("should get program options with position", () => { 26 | const options = getProgramOptions( 27 | buildProgramWithPosition({ 28 | overrides: { edgeEnd: faker.datatype.float() }, 29 | }) 30 | ); 31 | const programPositionConverted = { 32 | ...options, 33 | position: omit(options.position, "edgeEnd"), 34 | }; 35 | expect(options).toEqual(programPositionConverted); 36 | expect(options).not.toEqual( 37 | buildProgramWithPosition({ 38 | overrides: { edgeEnd: faker.datatype.float() }, 39 | }) 40 | ); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/Epg/helpers/__tests__/time.test.ts: -------------------------------------------------------------------------------- 1 | import { formatTime, isYesterday } from '../time'; 2 | 3 | describe('Time helpers', () => { 4 | it('should format program time', () => { 5 | const time = '2022-02-01 23:50:00'; 6 | const timeFormatted = '2022-02-01T23:50:00'; 7 | expect(formatTime(time)).toBe(timeFormatted); 8 | 9 | expect(formatTime(new Date(time))).toBe(timeFormatted); 10 | expect(formatTime(new Date(time).getTime())).toBe(timeFormatted); 11 | expect(formatTime(new Date(time).getTime())).not.toBe( 12 | new Date(timeFormatted).getTime() 13 | ); 14 | 15 | expect(formatTime(time)).not.toBe('2022-02-01'); 16 | expect(formatTime(time)).not.toBe('2022-02-01 23:50'); 17 | expect(formatTime(time)).not.toBe('23:50:00'); 18 | }); 19 | 20 | it('should check if this is yesterday date', () => { 21 | const startDate = '2022-02-02 00:00:00'; 22 | const date = '2022-02-01 23:50:00'; 23 | expect(isYesterday(date, startDate)).toBe(true); 24 | expect(isYesterday(new Date(date), new Date(startDate))).toBe(true); 25 | expect( 26 | isYesterday(new Date(date).getTime(), new Date(startDate).getTime()) 27 | ).toBe(true); 28 | 29 | expect(isYesterday(date, startDate)).not.toBe(false); 30 | expect(isYesterday(startDate, date)).not.toBe(true); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/Epg/helpers/common.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useEffect } from "react"; 2 | import { differenceInHours, startOfDay } from "date-fns"; 3 | import { HOURS_IN_DAY } from "./variables"; 4 | 5 | type DateTime = string | number | Date; 6 | 7 | type OmitObjectType = { [key: string]: any }; 8 | export const omit = (obj: OmitObjectType, ...props: string[]) => { 9 | const result = { ...obj }; 10 | 11 | for (const property of props) { 12 | delete result[property]; 13 | } 14 | 15 | return result; 16 | }; 17 | 18 | export const generateArray = (num: number) => new Array(num).fill(""); 19 | 20 | type ProgramOptions = { 21 | position: { width: number; height: number; top: number; left: number }; 22 | }; 23 | export const getProgramOptions = (program: T) => { 24 | const { width, height, top, left } = program.position; 25 | return { 26 | ...program, 27 | position: { width, height, top, left }, 28 | }; 29 | }; 30 | 31 | export const useIsomorphicLayoutEffect = () => 32 | typeof window !== "undefined" ? useLayoutEffect : useEffect; 33 | 34 | export const getHourWidth = (dayWidth: number) => dayWidth / HOURS_IN_DAY; 35 | 36 | export const getDate = (date: DateTime) => new Date(date); 37 | 38 | const abs = (num: number) => Math.abs(num); 39 | interface DayWidth { 40 | dayWidth: number; 41 | startDate: DateTime; 42 | endDate: DateTime; 43 | } 44 | export const getDayWidthResources = ({ 45 | dayWidth, 46 | startDate, 47 | endDate, 48 | }: DayWidth) => { 49 | const startDateTime = getDate(startDate); 50 | const endDateTime = getDate(endDate); 51 | 52 | if (endDateTime < startDateTime) { 53 | console.error( 54 | `Invalid endDate property. Value of endDate must be greater than startDate. Props: startDateTime: ${startDateTime}, endDateTime: ${endDateTime}` 55 | ); 56 | } 57 | 58 | const offsetStartHoursRange = differenceInHours( 59 | startDateTime, 60 | startOfDay(startDateTime) 61 | ); 62 | 63 | const numberOfHoursInDay = differenceInHours(endDateTime, startDateTime); 64 | const hourWidth = Math.floor(dayWidth / numberOfHoursInDay); 65 | const newDayWidth = hourWidth * numberOfHoursInDay; 66 | 67 | return { 68 | hourWidth: abs(hourWidth), 69 | dayWidth: abs(newDayWidth), 70 | numberOfHoursInDay: abs(numberOfHoursInDay), 71 | offsetStartHoursRange: abs(offsetStartHoursRange), 72 | }; 73 | }; 74 | -------------------------------------------------------------------------------- /src/Epg/helpers/enums.ts: -------------------------------------------------------------------------------- 1 | export enum Layers { 2 | Sidebar = 10, 3 | EpgCornerBox = 90, 4 | Line = 10, 5 | Loader = 100, 6 | Program = 1, 7 | Timeline = 10, 8 | } 9 | -------------------------------------------------------------------------------- /src/Epg/helpers/epg.ts: -------------------------------------------------------------------------------- 1 | import { differenceInMinutes, getTime } from "date-fns"; 2 | 3 | // Import interfaces 4 | import { Channel, Program } from "./interfaces"; 5 | 6 | // Import types 7 | import { ProgramWithPosition, Position, DateTime } from "./types"; 8 | 9 | // Import variables 10 | import { HOUR_IN_MINUTES } from "./variables"; 11 | 12 | // Import time helpers 13 | import { 14 | formatTime, 15 | roundToMinutes, 16 | isYesterday as isYesterdayTime, 17 | } from "./time"; 18 | import { getDate } from "./common"; 19 | 20 | // -------- Program width -------- 21 | const getItemDiffWidth = (diff: number, hourWidth: number) => 22 | (diff * hourWidth) / HOUR_IN_MINUTES; 23 | 24 | export const getPositionX = ( 25 | since: DateTime, 26 | till: DateTime, 27 | startDate: DateTime, 28 | endDate: DateTime, 29 | hourWidth: number 30 | ) => { 31 | const isTomorrow = getTime(getDate(till)) > getTime(getDate(endDate)); 32 | const isYesterday = getTime(getDate(since)) < getTime(getDate(startDate)); 33 | 34 | // When time range is set to 1 hour and program time is greater than 1 hour 35 | if (isYesterday && isTomorrow) { 36 | const diffTime = differenceInMinutes( 37 | roundToMinutes(getDate(endDate)), 38 | getDate(startDate) 39 | ); 40 | return getItemDiffWidth(diffTime, hourWidth); 41 | } 42 | 43 | if (isYesterday) { 44 | const diffTime = differenceInMinutes( 45 | roundToMinutes(getDate(till)), 46 | getDate(startDate) 47 | ); 48 | return getItemDiffWidth(diffTime, hourWidth); 49 | } 50 | 51 | if (isTomorrow) { 52 | const diffTime = differenceInMinutes( 53 | getDate(endDate), 54 | roundToMinutes(getDate(since)) 55 | ); 56 | 57 | if (diffTime < 0) return 0; 58 | return getItemDiffWidth(diffTime, hourWidth); 59 | } 60 | 61 | const diffTime = differenceInMinutes( 62 | roundToMinutes(getDate(till)), 63 | roundToMinutes(getDate(since)) 64 | ); 65 | 66 | return getItemDiffWidth(diffTime, hourWidth); 67 | }; 68 | 69 | // -------- Channel position in the Epg -------- 70 | export const getChannelPosition = ( 71 | channelIndex: number, 72 | itemHeight: number 73 | ) => { 74 | const top = itemHeight * channelIndex; 75 | const position = { 76 | top, 77 | height: itemHeight, 78 | }; 79 | return position; 80 | }; 81 | // -------- Program position in the Epg -------- 82 | export const getProgramPosition = ( 83 | program: Program, 84 | channelIndex: number, 85 | itemHeight: number, 86 | hourWidth: number, 87 | startDate: DateTime, 88 | endDate: DateTime 89 | ) => { 90 | const item = { 91 | ...program, 92 | since: formatTime(program.since), 93 | till: formatTime(program.till), 94 | }; 95 | const isYesterday = isYesterdayTime(item.since, startDate); 96 | 97 | let width = getPositionX( 98 | item.since, 99 | item.till, 100 | startDate, 101 | endDate, 102 | hourWidth 103 | ); 104 | const top = itemHeight * channelIndex; 105 | let left = getPositionX(startDate, item.since, startDate, endDate, hourWidth); 106 | const edgeEnd = getPositionX( 107 | startDate, 108 | item.till, 109 | startDate, 110 | endDate, 111 | hourWidth 112 | ); 113 | 114 | if (isYesterday) left = 0; 115 | // If item has negative top position, it means that it is not visible in this day 116 | if (top < 0) width = 0; 117 | 118 | const position = { 119 | width, 120 | height: itemHeight, 121 | top, 122 | left, 123 | edgeEnd, 124 | }; 125 | return { position, data: item }; 126 | }; 127 | 128 | // -------- Converted programs with position data -------- 129 | interface ConvertedPrograms { 130 | data: Program[]; 131 | channels: Channel[]; 132 | startDate: DateTime; 133 | endDate: DateTime; 134 | itemHeight: number; 135 | hourWidth: number; 136 | } 137 | export const getConvertedPrograms = ({ 138 | data, 139 | channels, 140 | startDate, 141 | endDate, 142 | itemHeight, 143 | hourWidth, 144 | }: ConvertedPrograms) => 145 | data.map((next) => { 146 | const channelIndex = channels.findIndex( 147 | ({ uuid }) => uuid === next.channelUuid 148 | ); 149 | return getProgramPosition( 150 | next, 151 | channelIndex, 152 | itemHeight, 153 | hourWidth, 154 | startDate, 155 | endDate 156 | ); 157 | }, [] as ProgramWithPosition[]); 158 | 159 | // -------- Converted channels with position data -------- 160 | export const getConvertedChannels = (channels: Channel[], itemHeight: number) => 161 | channels.map((channel, index) => ({ 162 | ...channel, 163 | position: getChannelPosition(index, itemHeight), 164 | })); 165 | 166 | // -------- Dynamic virtual program visibility in the EPG -------- 167 | export const getItemVisibility = ( 168 | position: Position, 169 | scrollY: number, 170 | scrollX: number, 171 | containerHeight: number, 172 | containerWidth: number, 173 | itemOverscan: number 174 | ) => { 175 | if (position.width <= 0) { 176 | return false; 177 | } 178 | 179 | if (scrollY > position.top + itemOverscan * 3) { 180 | return false; 181 | } 182 | 183 | if (scrollY + containerHeight <= position.top) { 184 | return false; 185 | } 186 | 187 | if ( 188 | scrollX + containerWidth >= position.left && 189 | scrollX <= position.edgeEnd 190 | ) { 191 | return true; 192 | } 193 | 194 | return false; 195 | }; 196 | 197 | export const getSidebarItemVisibility = ( 198 | position: Pick, 199 | scrollY: number, 200 | containerHeight: number, 201 | itemOverscan: number 202 | ) => { 203 | if (scrollY > position.top + itemOverscan * 3) { 204 | return false; 205 | } 206 | 207 | if (scrollY + containerHeight <= position.top) { 208 | return false; 209 | } 210 | 211 | return true; 212 | }; 213 | -------------------------------------------------------------------------------- /src/Epg/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./interfaces"; 2 | export * from "./enums"; 3 | export * from "./variables"; 4 | export * from "./common"; 5 | export * from "./time"; 6 | export * from "./epg"; 7 | -------------------------------------------------------------------------------- /src/Epg/helpers/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface Program { 2 | channelUuid: string; 3 | id: string; 4 | title: string; 5 | description: string; 6 | since: string | number | Date; 7 | till: string | number | Date; 8 | image: string; 9 | [key: string]: any; 10 | } 11 | 12 | export interface Channel { 13 | uuid: string; 14 | logo: string; 15 | [key: string]: any; 16 | } 17 | 18 | export interface Theme { 19 | primary: { 20 | 600: string; 21 | 900: string; 22 | }; 23 | grey: { 300: string }; 24 | white: string; 25 | green: { 300: string }; 26 | loader: { 27 | teal: string; 28 | purple: string; 29 | pink: string; 30 | bg: string; 31 | }; 32 | scrollbar: { 33 | border: string; 34 | thumb: { 35 | bg: string; 36 | }; 37 | }; 38 | gradient: { 39 | blue: { 40 | 300: string; 41 | 600: string; 42 | 900: string; 43 | }; 44 | }; 45 | 46 | text: { 47 | grey: { 48 | 300: string; 49 | 500: string; 50 | }; 51 | }; 52 | timeline: { 53 | divider: { 54 | bg: string; 55 | }; 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/Epg/helpers/time.ts: -------------------------------------------------------------------------------- 1 | import { format, roundToNearestMinutes, startOfDay, addDays } from "date-fns"; 2 | 3 | // Import types 4 | import { DateTime as DateRangeTime } from "./types"; 5 | 6 | // Variables 7 | import { TIME_FORMAT } from "./variables"; 8 | 9 | type DateTime = number | string | Date; 10 | 11 | const getTime = (date: DateTime) => new Date(date).getTime(); 12 | 13 | export const getLiveStatus = (since: DateTime, till: DateTime) => { 14 | const nowTime = getTime(new Date()); 15 | const sinceTime = getTime(since); 16 | const sinceTill = getTime(till); 17 | return nowTime >= sinceTime && sinceTill > nowTime; 18 | }; 19 | 20 | export const formatTime = (date: DateTime) => 21 | format(new Date(date), TIME_FORMAT.DEFAULT).replace(/\s/g, "T"); 22 | 23 | export const roundToMinutes = (date: DateTime) => 24 | roundToNearestMinutes(new Date(date)); 25 | 26 | export const isYesterday = (since: DateTime, startDate: DateTime) => { 27 | const sinceTime = getTime(new Date(since)); 28 | const startDateTime = getTime(new Date(startDate)); 29 | 30 | return startDateTime > sinceTime; 31 | }; 32 | 33 | export const isFutureTime = (date: DateTime) => { 34 | const dateTime = getTime(new Date(date)); 35 | const now = getTime(new Date()); 36 | return dateTime > now; 37 | }; 38 | 39 | export const getTimeRangeDates = ( 40 | startDate: DateRangeTime, 41 | endDate: DateRangeTime 42 | ) => { 43 | let endDateValue = endDate; 44 | if (endDate === "") { 45 | endDateValue = formatTime(startOfDay(addDays(new Date(startDate), 1))); 46 | } 47 | 48 | return { startDate, endDate: endDateValue }; 49 | }; 50 | -------------------------------------------------------------------------------- /src/Epg/helpers/types.ts: -------------------------------------------------------------------------------- 1 | // Interfaces 2 | import { Program, Channel } from "./interfaces"; 3 | 4 | export type Position = { 5 | width: number; 6 | height: number; 7 | top: number; 8 | left: number; 9 | edgeEnd: number; 10 | }; 11 | 12 | export type ProgramWithPosition = { 13 | position: Position; 14 | data: Program; 15 | }; 16 | 17 | export type ProgramItem = { 18 | position: Omit; 19 | data: Program; 20 | }; 21 | 22 | export type ChannelWithPosition = Channel & { 23 | position: Pick; 24 | }; 25 | 26 | export type DateTime = string | Date; 27 | 28 | export type BaseTimeFormat = boolean; 29 | -------------------------------------------------------------------------------- /src/Epg/helpers/variables.ts: -------------------------------------------------------------------------------- 1 | // Dimensions 2 | export const DAY_WIDTH = 7200; 3 | export const HOURS_IN_DAY = 24; 4 | 5 | export const HOUR_IN_MINUTES = 60; 6 | 7 | export const TIMELINE_HEIGHT = 60; 8 | 9 | export const SIDEBAR_WIDTH = 100; 10 | export const ITEM_HEIGHT = 80; 11 | 12 | export const ITEM_OVERSCAN = ITEM_HEIGHT; 13 | 14 | // Debounce 15 | export const DEBOUNCE_WAIT = 100; 16 | export const DEBOUNCE_WAIT_MAX = 100; 17 | 18 | // Program refresh 19 | export const PROGRAM_REFRESH = 120000; 20 | 21 | // Theme 22 | export const THEME_MODE = { 23 | DARK: "dark", 24 | LIGHT: "light", 25 | }; 26 | 27 | export const TIME_FORMAT = { 28 | DEFAULT: "yyyy-MM-dd HH:mm:ss", 29 | DATE: "yyyy-MM-dd", 30 | HOURS_MIN: "HH:mm", 31 | BASE_HOURS_TIME: "h:mm a", 32 | }; 33 | -------------------------------------------------------------------------------- /src/Epg/hooks/__tests__/useLayout.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react-hooks"; 2 | import { useLayout } from "../useLayout"; 3 | 4 | const defaultState = (overrides: { [key: string]: any } = {}) => { 5 | return { 6 | containerRef: { current: null }, 7 | scrollBoxRef: { current: null }, 8 | scrollX: 0, 9 | scrollY: 0, 10 | layoutWidth: undefined, 11 | layoutHeight: undefined, 12 | onScroll: expect.any(Function), 13 | onScrollToNow: expect.any(Function), 14 | onScrollTop: expect.any(Function), 15 | onScrollLeft: expect.any(Function), 16 | onScrollRight: expect.any(Function), 17 | ...overrides, 18 | }; 19 | }; 20 | 21 | test("should return initial useLayout props", () => { 22 | const { result } = renderHook(() => 23 | useLayout({ 24 | sidebarWidth: 100, 25 | startDate: "2022-03-24T00:00:00", 26 | }) 27 | ); 28 | 29 | expect(result.current).toEqual(defaultState()); 30 | }); 31 | 32 | test("should set initial width and height useLayout props", () => { 33 | const width = 800; 34 | const height = 600; 35 | const { result } = renderHook(() => 36 | useLayout({ 37 | width, 38 | height, 39 | sidebarWidth: 100, 40 | startDate: "2022-03-24T00:00:00", 41 | }) 42 | ); 43 | 44 | expect(result.current).toEqual( 45 | defaultState({ layoutWidth: width, layoutHeight: height }) 46 | ); 47 | }); 48 | -------------------------------------------------------------------------------- /src/Epg/hooks/__tests__/useProgram.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react-hooks"; 2 | import { useProgram } from "../useProgram"; 3 | import { buildProgramWithPosition, getTestTimeDate } from "../../test"; 4 | import { ProgramItem } from "../../helpers/types"; 5 | 6 | interface DefaultState { 7 | overrides?: { [key: string]: any }; 8 | styles?: { [key: string]: any }; 9 | } 10 | const defaultState = ({ overrides, styles }: DefaultState = {}) => { 11 | return { 12 | formatTime: expect.any(Function), 13 | set12HoursTimeFormat: expect.any(Function), 14 | getRTLSinceTime: expect.any(Function), 15 | getRTLTillTime: expect.any(Function), 16 | isLive: false, 17 | isMinWidth: true, 18 | isRTL: false, 19 | styles, 20 | ...overrides, 21 | }; 22 | }; 23 | 24 | function getStyles(program: ProgramItem) { 25 | return { 26 | styles: { position: program.position, width: program.position.width }, 27 | }; 28 | } 29 | 30 | test("should return generated props from useTimeline", () => { 31 | const program = buildProgramWithPosition(); 32 | const props = { program, isBaseTimeFormat: false }; 33 | const { result } = renderHook(() => useProgram(props)); 34 | const options = { 35 | styles: { position: program.position, width: program.position.width }, 36 | }; 37 | 38 | const { formatTime } = result.current; 39 | expect(formatTime(getTestTimeDate("08", "20"))).toBe("08:20"); 40 | expect(formatTime(getTestTimeDate("18", "20"))).toBe("18:20"); 41 | 42 | expect(result.current).toEqual(defaultState(options)); 43 | }); 44 | 45 | test("should specify an initial state in useTimeline", () => { 46 | const program = buildProgramWithPosition(); 47 | const props = { 48 | program, 49 | isBaseTimeFormat: true, 50 | minWidth: 800, 51 | }; 52 | const { result } = renderHook(() => useProgram(props)); 53 | const options = { 54 | ...getStyles(program), 55 | isMinWidth: false, 56 | }; 57 | const { formatTime, set12HoursTimeFormat } = result.current; 58 | expect(formatTime(getTestTimeDate("02", "33"), set12HoursTimeFormat())).toBe( 59 | "2:33AM" 60 | ); 61 | expect(formatTime(getTestTimeDate("15", "45"), set12HoursTimeFormat())).toBe( 62 | "3:45PM" 63 | ); 64 | 65 | expect(result.current).toEqual(defaultState(options)); 66 | }); 67 | -------------------------------------------------------------------------------- /src/Epg/hooks/__tests__/useTimeline.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react-hooks"; 2 | import { useTimeline } from "../useTimeline"; 3 | 4 | test("should return generated props from useTimeline", () => { 5 | const { result } = renderHook(() => useTimeline(24, false)); 6 | expect(result.current.time).toHaveLength(24); 7 | expect(result.current.dividers).toHaveLength(4); 8 | expect(result.current.formatTime(10)).toBe("10:00"); 9 | expect(result.current.formatTime(8)).toBe("08:00"); 10 | }); 11 | 12 | test("should specify an 12 hours initial state in useTimeline", () => { 13 | const { result } = renderHook(() => useTimeline(16, true)); 14 | expect(result.current.time).toHaveLength(16); 15 | expect(result.current.dividers).toHaveLength(4); 16 | expect(result.current.formatTime(10)).toBe("10:00am"); 17 | expect(result.current.formatTime(14)).toBe("2:00pm"); 18 | }); 19 | -------------------------------------------------------------------------------- /src/Epg/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useEpg"; 2 | export * from "./useInterval"; 3 | export * from "./useProgram"; 4 | export * from "./useTimeline"; 5 | -------------------------------------------------------------------------------- /src/Epg/hooks/useEpg.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { startOfToday } from "date-fns"; 3 | 4 | // Import interfaces 5 | import { Channel, Program, Theme } from "../helpers/interfaces"; 6 | 7 | // Import types 8 | import { DateTime, BaseTimeFormat, Position } from "../helpers/types"; 9 | 10 | // Import helpers 11 | import { 12 | DAY_WIDTH, 13 | ITEM_HEIGHT, 14 | ITEM_OVERSCAN, 15 | getDayWidthResources, 16 | getTimeRangeDates, 17 | } from "../helpers"; 18 | 19 | // Import theme 20 | import { theme as defaultTheme } from "../theme"; 21 | 22 | // Import helpers 23 | import { 24 | SIDEBAR_WIDTH, 25 | formatTime, 26 | getConvertedChannels, 27 | getConvertedPrograms, 28 | getItemVisibility, 29 | getSidebarItemVisibility, 30 | } from "../helpers"; 31 | 32 | // Import components 33 | import { useLayout } from "./useLayout"; 34 | 35 | interface useEpgProps { 36 | channels: Channel[]; 37 | epg: Program[]; 38 | width?: number; 39 | height?: number; 40 | startDate?: DateTime; 41 | endDate?: DateTime; 42 | isBaseTimeFormat?: BaseTimeFormat; 43 | isSidebar?: boolean; 44 | isTimeline?: boolean; 45 | isRTL?: boolean; 46 | isLine?: boolean; 47 | theme?: Theme; 48 | globalStyles?: string; 49 | dayWidth?: number; 50 | sidebarWidth?: number; 51 | itemHeight?: number; 52 | itemOverscan?: number; 53 | } 54 | 55 | const defaultStartDateTime = formatTime(startOfToday()); 56 | 57 | export function useEpg({ 58 | channels: channelsEpg, 59 | epg, 60 | startDate: startDateInput = defaultStartDateTime, 61 | endDate: endDateInput = "", 62 | isRTL = false, 63 | isBaseTimeFormat = false, 64 | isSidebar = true, 65 | isTimeline = true, 66 | isLine = true, 67 | theme: customTheme, 68 | globalStyles, 69 | dayWidth: customDayWidth = DAY_WIDTH, 70 | sidebarWidth = SIDEBAR_WIDTH, 71 | itemHeight = ITEM_HEIGHT, 72 | itemOverscan = ITEM_OVERSCAN, 73 | width, 74 | height, 75 | }: useEpgProps) { 76 | // Get converted start and end dates 77 | const { startDate, endDate } = getTimeRangeDates( 78 | startDateInput, 79 | endDateInput 80 | ); 81 | 82 | // Get day and hour width of the day 83 | const { hourWidth, dayWidth, ...dayWidthResourcesProps } = React.useMemo( 84 | () => 85 | getDayWidthResources({ dayWidth: customDayWidth, startDate, endDate }), 86 | [customDayWidth, startDate, endDate] 87 | ); 88 | 89 | // -------- Effects -------- 90 | const { containerRef, scrollBoxRef, ...layoutProps } = useLayout({ 91 | startDate, 92 | endDate, 93 | sidebarWidth, 94 | width, 95 | height, 96 | hourWidth, 97 | }); 98 | 99 | const { scrollX, scrollY, layoutWidth, layoutHeight } = layoutProps; 100 | const { 101 | onScroll, 102 | onScrollToNow, 103 | onScrollTop, 104 | onScrollLeft, 105 | onScrollRight, 106 | } = layoutProps; 107 | 108 | //-------- Variables -------- 109 | const channels = React.useMemo( 110 | () => getConvertedChannels(channelsEpg, itemHeight), 111 | [channelsEpg, itemHeight] 112 | ); 113 | 114 | const startDateTime = formatTime(startDate); 115 | const endDateTime = formatTime(endDate); 116 | const programs = React.useMemo( 117 | () => 118 | getConvertedPrograms({ 119 | data: epg, 120 | channels, 121 | startDate: startDateTime, 122 | endDate: endDateTime, 123 | itemHeight, 124 | hourWidth, 125 | }), 126 | [epg, channels, startDateTime, endDateTime, itemHeight, hourWidth] 127 | ); 128 | 129 | const theme: Theme = customTheme || defaultTheme; 130 | 131 | // -------- Handlers -------- 132 | const isProgramVisible = React.useCallback( 133 | (position: Position) => 134 | getItemVisibility( 135 | position, 136 | scrollY, 137 | scrollX, 138 | layoutHeight, 139 | layoutWidth, 140 | itemOverscan 141 | ), 142 | [scrollY, scrollX, layoutHeight, layoutWidth, itemOverscan] 143 | ); 144 | 145 | const isChannelVisible = React.useCallback( 146 | (position: Pick) => 147 | getSidebarItemVisibility(position, scrollY, layoutHeight, itemOverscan), 148 | [scrollY, layoutHeight, itemOverscan] 149 | ); 150 | 151 | const getEpgProps = () => ({ 152 | isRTL, 153 | isSidebar, 154 | isLine, 155 | isTimeline, 156 | width, 157 | height, 158 | sidebarWidth, 159 | ref: containerRef, 160 | theme, 161 | globalStyles, 162 | }); 163 | 164 | const getLayoutProps = () => ({ 165 | programs, 166 | channels, 167 | startDate, 168 | endDate, 169 | scrollY, 170 | onScroll, 171 | isRTL, 172 | isBaseTimeFormat, 173 | isSidebar, 174 | isTimeline, 175 | isLine, 176 | isProgramVisible, 177 | isChannelVisible, 178 | dayWidth, 179 | hourWidth, 180 | sidebarWidth, 181 | itemHeight, 182 | ...dayWidthResourcesProps, 183 | ref: scrollBoxRef, 184 | }); 185 | 186 | return { 187 | getEpgProps, 188 | getLayoutProps, 189 | onScrollToNow, 190 | onScrollTop, 191 | onScrollLeft, 192 | onScrollRight, 193 | scrollY, 194 | scrollX, 195 | }; 196 | } 197 | -------------------------------------------------------------------------------- /src/Epg/hooks/useInterval.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // Import helpers 4 | import { useIsomorphicLayoutEffect } from "../helpers"; 5 | 6 | export function useInterval(callback: () => void, delay: number | null) { 7 | const useIsomorphicEffect = useIsomorphicLayoutEffect(); 8 | const savedCallback = React.useRef(callback); 9 | 10 | useIsomorphicEffect(() => { 11 | savedCallback.current = callback; 12 | }, [callback]); 13 | 14 | React.useEffect(() => { 15 | if (!delay && delay !== 0) { 16 | return; 17 | } 18 | 19 | const id = setInterval(() => savedCallback.current(), delay); 20 | 21 | return () => clearInterval(id); 22 | }, [delay]); 23 | } 24 | -------------------------------------------------------------------------------- /src/Epg/hooks/useLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useDebouncedCallback } from "use-debounce"; 3 | import { startOfToday, isToday as isTodayFns } from "date-fns"; 4 | 5 | // Import types 6 | import { DateTime } from "../helpers/types"; 7 | 8 | // Import helpers 9 | import { 10 | DEBOUNCE_WAIT, 11 | DEBOUNCE_WAIT_MAX, 12 | getPositionX, 13 | useIsomorphicLayoutEffect, 14 | } from "../helpers"; 15 | 16 | interface useLayoutProps { 17 | height?: number; 18 | width?: number; 19 | hourWidth: number; 20 | sidebarWidth: number; 21 | startDate: DateTime; 22 | endDate: DateTime; 23 | } 24 | 25 | export function useLayout({ 26 | height, 27 | width, 28 | startDate, 29 | endDate, 30 | hourWidth, 31 | sidebarWidth, 32 | }: useLayoutProps) { 33 | const useIsomorphicEffect = useIsomorphicLayoutEffect(); 34 | 35 | const containerRef = React.useRef(null); 36 | const scrollBoxRef = React.useRef(null); 37 | //-------- State -------- 38 | const [scrollY, setScrollY] = React.useState(0); 39 | const [scrollX, setScrollX] = React.useState(0); 40 | const [layoutWidth, setLayoutWidth] = React.useState(width as number); 41 | const [layoutHeight, setLayoutHeight] = React.useState( 42 | height as number 43 | ); 44 | const isToday = isTodayFns(new Date(startDate)); 45 | 46 | // -------- Handlers -------- 47 | const handleScrollDebounced = useDebouncedCallback( 48 | (value) => { 49 | setScrollY(value.y); 50 | setScrollX(value.x); 51 | }, 52 | DEBOUNCE_WAIT, 53 | { maxWait: DEBOUNCE_WAIT_MAX } 54 | ); 55 | 56 | const handleOnScroll = React.useCallback( 57 | (e: React.UIEvent & { target: Element }) => { 58 | handleScrollDebounced({ y: e.target.scrollTop, x: e.target.scrollLeft }); 59 | }, 60 | [handleScrollDebounced] 61 | ); 62 | 63 | const handleOnScrollToNow = React.useCallback(() => { 64 | if (scrollBoxRef?.current && isToday) { 65 | const clientWidth = (width ?? 66 | containerRef.current?.clientWidth) as number; 67 | 68 | const newDate = new Date(); 69 | const scrollPosition = getPositionX( 70 | startOfToday(), 71 | newDate, 72 | startDate, 73 | endDate, 74 | hourWidth 75 | ); 76 | const scrollNow = scrollPosition - clientWidth / 2 + sidebarWidth; 77 | scrollBoxRef.current.scrollLeft = scrollNow; 78 | } 79 | }, [isToday, startDate, endDate, width, sidebarWidth, hourWidth]); 80 | 81 | const handleOnScrollTop = React.useCallback( 82 | (value: number = hourWidth) => { 83 | if (scrollBoxRef?.current) { 84 | const top = scrollBoxRef.current.scrollTop + value; 85 | scrollBoxRef.current.scrollTop = top; 86 | } 87 | }, 88 | [hourWidth] 89 | ); 90 | 91 | const handleOnScrollRight = React.useCallback( 92 | (value: number = hourWidth) => { 93 | if (scrollBoxRef?.current) { 94 | const right = scrollBoxRef.current.scrollLeft + value; 95 | scrollBoxRef.current.scrollLeft = right; 96 | } 97 | }, 98 | [hourWidth] 99 | ); 100 | 101 | const handleOnScrollLeft = React.useCallback( 102 | (value: number = hourWidth) => { 103 | if (scrollBoxRef?.current) { 104 | const left = scrollBoxRef.current.scrollLeft - value; 105 | scrollBoxRef.current.scrollLeft = left; 106 | } 107 | }, 108 | [hourWidth] 109 | ); 110 | 111 | const handleResizeDebounced = useDebouncedCallback( 112 | () => { 113 | if (containerRef?.current && !width) { 114 | const container = containerRef.current; 115 | const { clientWidth } = container; 116 | setLayoutWidth(clientWidth); 117 | } 118 | }, 119 | DEBOUNCE_WAIT * 4, 120 | { maxWait: DEBOUNCE_WAIT_MAX * 4 } 121 | ); 122 | 123 | // -------- Efffects -------- 124 | useIsomorphicEffect(() => { 125 | if (containerRef?.current) { 126 | const container = containerRef.current; 127 | if (!width) { 128 | const { clientWidth } = container; 129 | setLayoutWidth(clientWidth); 130 | } 131 | if (!height) { 132 | const { clientHeight } = container; 133 | setLayoutHeight(clientHeight); 134 | } 135 | } 136 | 137 | if (scrollBoxRef?.current && isToday) { 138 | handleOnScrollToNow(); 139 | } 140 | }, [height, width, startDate, isToday, handleOnScrollToNow]); 141 | 142 | useIsomorphicEffect(() => { 143 | window.addEventListener("resize", handleResizeDebounced); 144 | 145 | return () => { 146 | window.removeEventListener("resize", handleResizeDebounced); 147 | }; 148 | }, [width]); 149 | 150 | return { 151 | containerRef, 152 | scrollBoxRef, 153 | scrollX, 154 | scrollY, 155 | layoutWidth, 156 | layoutHeight, 157 | onScroll: handleOnScroll, 158 | onScrollToNow: handleOnScrollToNow, 159 | onScrollTop: handleOnScrollTop, 160 | onScrollLeft: handleOnScrollLeft, 161 | onScrollRight: handleOnScrollRight, 162 | }; 163 | } 164 | -------------------------------------------------------------------------------- /src/Epg/hooks/useLine.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { startOfDay } from "date-fns"; 3 | 4 | // Import types 5 | import { DateTime } from "../helpers/types"; 6 | 7 | // Import helpers 8 | import { HOUR_IN_MINUTES, PROGRAM_REFRESH, getPositionX } from "../helpers"; 9 | 10 | // Import hooks 11 | import { useInterval } from "."; 12 | 13 | interface useLineProps { 14 | startDate: DateTime; 15 | endDate: DateTime; 16 | dayWidth: number; 17 | hourWidth: number; 18 | sidebarWidth: number; 19 | } 20 | 21 | export function useLine({ 22 | startDate, 23 | endDate, 24 | dayWidth, 25 | hourWidth, 26 | sidebarWidth, 27 | }: useLineProps) { 28 | const initialState = 29 | getPositionX( 30 | startOfDay(new Date(startDate)), 31 | new Date(), 32 | startDate, 33 | endDate, 34 | hourWidth 35 | ) + sidebarWidth; 36 | const [positionX, setPositionX] = React.useState(() => initialState); 37 | 38 | const isDayEnd = positionX <= dayWidth; 39 | const isScrollX = React.useMemo(() => (isDayEnd ? PROGRAM_REFRESH : null), [ 40 | isDayEnd, 41 | ]); 42 | 43 | useInterval(() => { 44 | const offset = hourWidth / HOUR_IN_MINUTES; 45 | const positionOffset = offset * 2; 46 | setPositionX((prev) => prev + positionOffset); 47 | }, isScrollX); 48 | 49 | React.useEffect(() => { 50 | const date = new Date(startDate); 51 | const positionX = getPositionX( 52 | startOfDay(date), 53 | new Date(), 54 | startDate, 55 | endDate, 56 | hourWidth 57 | ); 58 | const newPositionX = positionX + sidebarWidth; 59 | setPositionX(newPositionX); 60 | }, [startDate, endDate, sidebarWidth, hourWidth]); 61 | 62 | return { positionX }; 63 | } 64 | -------------------------------------------------------------------------------- /src/Epg/hooks/useProgram.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { format } from "date-fns"; 3 | 4 | // Import types 5 | import { ProgramItem, BaseTimeFormat } from "../helpers/types"; 6 | 7 | // Import helpers 8 | import { PROGRAM_REFRESH, TIME_FORMAT, getLiveStatus, omit } from "../helpers"; 9 | 10 | // Import hooks 11 | import { useInterval } from "./useInterval"; 12 | 13 | interface useProgramProps { 14 | program: T; 15 | isRTL?: boolean; 16 | isBaseTimeFormat: BaseTimeFormat; 17 | minWidth?: number; 18 | } 19 | 20 | export function useProgram({ 21 | isRTL = false, 22 | isBaseTimeFormat, 23 | program, 24 | minWidth = 200, 25 | }: useProgramProps) { 26 | const { data, position } = program; 27 | const { width } = position; 28 | 29 | const { since, till } = data; 30 | const [isLive, setIsLive] = React.useState(() => 31 | getLiveStatus(since, till) 32 | ); 33 | 34 | const newPosition = omit(position, "egdeEnd"); 35 | 36 | const formatTime = ( 37 | date: string | number | Date, 38 | formatType: string = TIME_FORMAT.HOURS_MIN 39 | ) => format(new Date(date), formatType).replace(/\s/g, ""); 40 | 41 | const set12HoursTimeFormat = () => { 42 | if (isBaseTimeFormat) return TIME_FORMAT.BASE_HOURS_TIME; 43 | return TIME_FORMAT.HOURS_MIN; 44 | }; 45 | 46 | const getRTLSinceTime = (since: string | number | Date) => 47 | isRTL ? till : since; 48 | const getRTLTillTime = (till: string | number | Date) => 49 | isRTL ? since : till; 50 | 51 | useInterval(() => { 52 | const status = getLiveStatus(since, till); 53 | setIsLive(status); 54 | }, PROGRAM_REFRESH); 55 | 56 | const isMinWidth = width > minWidth; 57 | 58 | return { 59 | isLive, 60 | isMinWidth, 61 | isRTL, 62 | formatTime, 63 | set12HoursTimeFormat, 64 | getRTLSinceTime, 65 | getRTLTillTime, 66 | styles: { width, position: newPosition }, 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /src/Epg/hooks/useTimeline.tsx: -------------------------------------------------------------------------------- 1 | import { format } from "date-fns"; 2 | 3 | // Import types 4 | import { BaseTimeFormat } from "../helpers/types"; 5 | 6 | // Import helpers 7 | import { TIME_FORMAT, generateArray } from "../helpers"; 8 | 9 | export function useTimeline( 10 | numberOfHoursInDay: number, 11 | isBaseTimeFormat: BaseTimeFormat 12 | ) { 13 | const time = generateArray(numberOfHoursInDay); 14 | const dividers = generateArray(4); 15 | 16 | const formatTime = (index: number) => { 17 | const date = new Date(); 18 | const baseDate = format(date, TIME_FORMAT.DATE); 19 | const time = index < 10 ? `0${index}` : index; 20 | 21 | if (isBaseTimeFormat && index <= 24) { 22 | const date = new Date(`${baseDate}T${time}:00:00`); 23 | const timeFormat = format(date, TIME_FORMAT.BASE_HOURS_TIME); 24 | return timeFormat.toLowerCase().replace(/\s/g, ""); 25 | } 26 | 27 | return `${time}:00`; 28 | }; 29 | 30 | return { time, dividers, formatTime }; 31 | } 32 | -------------------------------------------------------------------------------- /src/Epg/index.ts: -------------------------------------------------------------------------------- 1 | import { ProgramStyled, ChannelStyled, TimelineStyled } from "./styles"; 2 | 3 | // Import types 4 | import { Theme as ThemeType, Program as IProgram } from "./helpers/interfaces"; 5 | import { 6 | ProgramItem as ProgramItemType, 7 | ChannelWithPosition, 8 | BaseTimeFormat as BaseTimeFormatType, 9 | } from "./helpers/types"; 10 | 11 | // Types 12 | export type Theme = ThemeType; 13 | export type Channel = ChannelWithPosition; 14 | export type Program = IProgram; 15 | export type ProgramItem = { 16 | program: ProgramItemType; 17 | isRTL: boolean; 18 | isBaseTimeFormat: BaseTimeFormatType; 19 | }; 20 | 21 | // Components 22 | export { Layout } from "./components"; 23 | export { Epg } from "./Epg"; 24 | export { useEpg, useProgram, useTimeline } from "./hooks"; 25 | 26 | // Styles 27 | const { ChannelBox, ChannelLogo } = ChannelStyled; 28 | 29 | const { 30 | ProgramBox, 31 | ProgramContent, 32 | ProgramFlex, 33 | ProgramStack, 34 | ProgramTitle, 35 | ProgramText, 36 | ProgramImage, 37 | } = ProgramStyled; 38 | 39 | const { 40 | TimelineWrapper, 41 | TimelineBox, 42 | TimelineTime, 43 | TimelineDivider, 44 | TimelineDividers, 45 | } = TimelineStyled; 46 | 47 | export { 48 | // Channel 49 | ChannelBox, 50 | ChannelLogo, 51 | // Program 52 | ProgramBox, 53 | ProgramContent, 54 | ProgramFlex, 55 | ProgramStack, 56 | ProgramTitle, 57 | ProgramText, 58 | ProgramImage, 59 | // Timeline 60 | TimelineWrapper, 61 | TimelineBox, 62 | TimelineTime, 63 | TimelineDividers, 64 | TimelineDivider, 65 | }; 66 | -------------------------------------------------------------------------------- /src/Epg/styles/Channel.styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled/macro"; 2 | import { Theme } from "../helpers"; 3 | 4 | export const ChannelBox = styled.div<{ 5 | top: number; 6 | height: number; 7 | theme?: Theme; 8 | }>` 9 | position: absolute; 10 | top: ${({ top }) => top}px; 11 | height: ${({ height }) => height}px; 12 | width: 100%; 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | background-color: ${({ theme }) => theme.primary[900]}; 17 | `; 18 | 19 | export const ChannelLogo = styled.img` 20 | max-height: 60px; 21 | max-width: 60px; 22 | position: relative; 23 | `; 24 | -------------------------------------------------------------------------------- /src/Epg/styles/Channels.styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled/macro"; 2 | import { Layers, Theme } from "../helpers"; 3 | 4 | export const Box = styled.div<{ 5 | isTimeline: boolean; 6 | isRTL: boolean; 7 | width: number; 8 | bottom: number; 9 | theme?: Theme; 10 | }>` 11 | position: sticky; 12 | width: ${({ width }) => width}px; 13 | float: left; 14 | bottom: ${({ bottom }) => bottom}px; 15 | left: 0; 16 | z-index: ${Layers.Sidebar}; 17 | background-color: ${({ theme }) => theme.primary[900]}; 18 | 19 | ${({ isRTL }) => isRTL && `transform: scale(-1,1)`}; 20 | `; 21 | -------------------------------------------------------------------------------- /src/Epg/styles/Epg.styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled/macro"; 2 | import { Layers, Theme } from "../helpers"; 3 | 4 | export const Container = styled.div<{ 5 | height?: number; 6 | width?: number; 7 | }>` 8 | padding: 5px; 9 | height: ${({ height }) => (height ? `${height}px` : "100%")}; 10 | width: ${({ width }) => (width ? `${width}px` : "100%")}; 11 | 12 | *, 13 | ::before, 14 | ::after { 15 | box-sizing: border-box; 16 | } 17 | `; 18 | 19 | export const Wrapper = styled.div` 20 | height: 100%; 21 | width: 100%; 22 | display: flex; 23 | flex-direction: column; 24 | position: relative; 25 | border-radius: 6px; 26 | overflow: hidden; 27 | `; 28 | 29 | export const ScrollBox = styled.div<{ theme?: Theme; isRTL?: boolean }>` 30 | height: 100%; 31 | width: 100%; 32 | position: relative; 33 | overflow: auto; 34 | scroll-behavior: smooth; 35 | background: ${({ theme }) => theme.primary[900]}; 36 | 37 | ${({ isRTL }) => isRTL && `transform: scale(-1,1)`}; 38 | 39 | ::-webkit-scrollbar { 40 | width: 10px; 41 | height: 10px; 42 | } 43 | 44 | ::-webkit-scrollbar-thumb { 45 | background: ${({ theme }) => theme.scrollbar.thumb.bg}; 46 | border: 10px none ${({ theme }) => theme.white}; 47 | border-radius: 20px; 48 | } 49 | ::-webkit-scrollbar-thumb:hover { 50 | background: ${({ theme }) => theme.white}; 51 | } 52 | 53 | ::-webkit-scrollbar-track { 54 | background: ${({ theme }) => theme.primary[900]}; 55 | border: 22px none ${({ theme }) => theme.white}; 56 | border-radius: 0px; 57 | } 58 | 59 | ::-webkit-scrollbar-corner { 60 | background: ${({ theme }) => theme.primary[900]}; 61 | } 62 | `; 63 | 64 | export const Box = styled.div<{ 65 | isRTL?: boolean; 66 | width: number; 67 | height: number; 68 | left?: number; 69 | top?: number; 70 | theme?: Theme; 71 | }>` 72 | position: absolute; 73 | height: ${({ height }) => height}px; 74 | width: ${({ width }) => width}px; 75 | top: ${({ top = 0 }) => top}px; 76 | background: ${({ theme }) => theme.primary[900]}; 77 | z-index: ${Layers.EpgCornerBox}; 78 | 79 | ${({ isRTL, left = 0 }) => (isRTL ? `right:0px;` : ` left: ${left}px`)}; 80 | `; 81 | 82 | export const Content = styled.div<{ 83 | width: number; 84 | height: number; 85 | sidebarWidth: number; 86 | isSidebar: boolean; 87 | theme?: Theme; 88 | }>` 89 | background: ${({ theme }) => theme.primary[900]}; 90 | height: ${({ height }) => height}px; 91 | width: ${({ width }) => width}px; 92 | position: relative; 93 | left: ${({ isSidebar, sidebarWidth }) => (isSidebar ? sidebarWidth : 0)}px; 94 | `; 95 | -------------------------------------------------------------------------------- /src/Epg/styles/Line.styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled/macro"; 2 | import { Layers, Theme } from "../helpers"; 3 | 4 | export const Box = styled.div<{ height: number; left: number; theme?: Theme }>` 5 | position: absolute; 6 | top: 64px; 7 | left: ${({ left }) => left}px; 8 | height: ${({ height }) => height}px; 9 | width: 3px; 10 | background: ${({ theme }) => theme.green[300]}; 11 | pointer-events: none; 12 | z-index: ${Layers.Line}; 13 | `; 14 | -------------------------------------------------------------------------------- /src/Epg/styles/Loader.styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled/macro"; 2 | import { keyframes } from "@emotion/react"; 3 | import { Layers, Theme } from "../helpers"; 4 | 5 | const time = [0, 50, 0]; 6 | 7 | const moveLeft = (animate: { right: string[] }) => keyframes` 8 | ${time.map( 9 | (item, index) => `${item}% { 10 | transform: translateX(-${animate.right[index]}); 11 | }` 12 | )} 13 | `; 14 | 15 | export const Box = styled.div<{ theme?: Theme }>` 16 | position: absolute; 17 | top: 0; 18 | left: 0; 19 | bottom: 0; 20 | width: 100%; 21 | background: ${({ theme }) => theme.loader.bg}; 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | z-index: ${Layers.Loader}; 26 | `; 27 | 28 | export const Shape = styled.div<{ 29 | width: number; 30 | color: string; 31 | marginRight?: number; 32 | transition: { duration: number; ease?: string; delay?: number }; 33 | animate: { right: string[] }; 34 | theme?: Theme; 35 | }>` 36 | width: ${({ width }) => width * 0.42}px; 37 | background: ${({ color }) => color}; 38 | height: 18px; 39 | border-radius: 45px; 40 | margin-right: ${({ marginRight }) => marginRight ?? 0}px; 41 | animation-name: ${({ animate }) => moveLeft(animate)}; 42 | animation-duration: ${({ transition }) => transition.duration}s; 43 | animation-timing-function: ${({ transition }) => 44 | transition.ease ?? "ease-in-out"}; 45 | animation-delay: ${({ transition }) => transition.delay ?? 0}s; 46 | animation-iteration-count: infinite; 47 | `; 48 | -------------------------------------------------------------------------------- /src/Epg/styles/Program.styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled/macro"; 2 | import { Layers, Theme } from "../helpers"; 3 | 4 | export const ProgramBox = styled.div<{ width: number }>` 5 | position: absolute; 6 | padding: ${({ width }) => (width === 0 ? 0 : 4)}px; 7 | overflow: hidden; 8 | `; 9 | 10 | export const ProgramContent = styled.div<{ 11 | isLive: boolean; 12 | width: number; 13 | theme?: Theme; 14 | }>` 15 | position: relative; 16 | display: flex; 17 | font-size: 11px; 18 | height: 100%; 19 | border-radius: 8px; 20 | padding: 10px ${({ width }) => (width < 30 ? 4 : 20)}px; 21 | overflow: hidden; 22 | cursor: pointer; 23 | transition: all 0.4s ease-in-out; 24 | background: ${({ theme: { primary } }) => 25 | `linear-gradient(to right, ${primary[600]}, ${primary[600]})`}; 26 | z-index: ${Layers.Program}; 27 | 28 | &:hover { 29 | background: ${({ theme: { gradient } }) => 30 | `linear-gradient(to right, ${gradient.blue[900]}, ${gradient.blue[600]})`}; 31 | } 32 | 33 | ${({ isLive, theme: { gradient } }) => 34 | isLive && 35 | `background: linear-gradient(to right, ${gradient.blue[900]}, ${gradient.blue[600]},${gradient.blue[300]})`} 36 | `; 37 | 38 | export const ProgramFlex = styled.div` 39 | width: 100%; 40 | display: flex; 41 | justify-content: flex-start; 42 | `; 43 | 44 | const Elipsis = ` 45 | white-space: nowrap; 46 | overflow: hidden; 47 | text-overflow: ellipsis; 48 | `; 49 | 50 | export const ProgramTitle = styled.p<{ theme?: Theme }>` 51 | font-size: 14px; 52 | text-align: left; 53 | margin-top: 0; 54 | margin-bottom: 5px; 55 | font-weight: 500; 56 | color: ${({ theme }) => theme.grey[300]}; 57 | ${Elipsis} 58 | `; 59 | 60 | export const ProgramText = styled.span<{ theme?: Theme }>` 61 | display: block; 62 | font-size: 12.5px; 63 | font-weight: 400; 64 | color: ${({ theme }) => theme.text.grey[500]}; 65 | text-align: left; 66 | ${Elipsis} 67 | `; 68 | 69 | export const ProgramImage = styled.img` 70 | margin-right: 15px; 71 | border-radius: 6px; 72 | width: 100px; 73 | `; 74 | 75 | export const ProgramStack = styled.div<{ isRTL?: boolean }>` 76 | overflow: hidden; 77 | ${({ isRTL }) => 78 | isRTL && 79 | `transform: scale(-1,1); 80 | display: flex; 81 | flex-direction: column; 82 | align-items: flex-end`}; 83 | `; 84 | -------------------------------------------------------------------------------- /src/Epg/styles/Timeline.styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled/macro"; 2 | import { Layers, Theme } from "../helpers"; 3 | 4 | // Import helpers 5 | import { ITEM_HEIGHT } from "../helpers"; 6 | 7 | export const TimelineTime = styled.span<{ 8 | theme?: Theme; 9 | isBaseTimeFormat?: boolean; 10 | isRTL?: boolean; 11 | }>` 12 | color: ${({ theme }) => theme.text.grey[300]}; 13 | position: absolute; 14 | top: 18px; 15 | left: ${({ isRTL, isBaseTimeFormat }) => 16 | isRTL && isBaseTimeFormat ? "-32" : "-18"}px; 17 | 18 | ${({ isRTL }) => isRTL && `transform: scale(-1,1)`}; 19 | `; 20 | 21 | export const TimelineDividers = styled.div` 22 | height: 100%; 23 | width: 100%; 24 | display: grid; 25 | grid-template-columns: repeat(4, 1fr); 26 | align-items: end; 27 | padding-bottom: 6px; 28 | `; 29 | 30 | export const TimelineDivider = styled.div<{ width: number; theme?: Theme }>` 31 | background: ${({ theme }) => theme.timeline.divider.bg}; 32 | height: 10px; 33 | width: 1px; 34 | margin-right: ${({ width }) => width / 4}px; 35 | `; 36 | 37 | export const TimelineWrapper = styled.div<{ 38 | isSidebar: boolean; 39 | dayWidth: number; 40 | sidebarWidth: number; 41 | theme?: Theme; 42 | }>` 43 | position: sticky; 44 | top: 0; 45 | left: ${({ isSidebar, sidebarWidth }) => (isSidebar ? sidebarWidth : 0)}px; 46 | display: flex; 47 | height: ${ITEM_HEIGHT - 20}px; 48 | width: ${({ dayWidth }) => dayWidth}px; 49 | background: ${({ theme }) => theme.primary[900]}; 50 | z-index: ${Layers.Timeline}; 51 | `; 52 | 53 | export const TimelineBox = styled.div<{ width: number }>` 54 | width: ${({ width }) => width}px; 55 | font-size: 14px; 56 | position: relative; 57 | 58 | &:first-of-type { 59 | ${TimelineTime} { 60 | left: 0px; 61 | } 62 | } 63 | `; 64 | -------------------------------------------------------------------------------- /src/Epg/styles/global.styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | 3 | export const globalStyles = css` 4 | @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"); 5 | 6 | .planby { 7 | font-family: "Inter", system-ui, -apple-system, 8 | /* Firefox supports this but not yet system-ui */ "Segoe UI", Roboto, 9 | Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; /* 2 */ 10 | } 11 | `; 12 | -------------------------------------------------------------------------------- /src/Epg/styles/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./global.styles"; 2 | export * as EpgStyled from "./Epg.styles"; 3 | export * as ChannelsStyled from "./Channels.styles"; 4 | export * as ChannelStyled from "./Channel.styles"; 5 | export * as ProgramStyled from "./Program.styles"; 6 | export * as TimelineStyled from "./Timeline.styles"; 7 | export * as LineStyled from "./Line.styles"; 8 | export * as LoaderStyled from './Loader.styles' 9 | -------------------------------------------------------------------------------- /src/Epg/test/db/channels.ts: -------------------------------------------------------------------------------- 1 | import faker from "@faker-js/faker"; 2 | 3 | const channelFakeData = { 4 | country: faker.address.country(), 5 | logo: faker.image.image(), 6 | provider: 333, 7 | title: faker.commerce.productName(), 8 | type: faker.commerce.product(), 9 | uuid: "09-3de-34", 10 | year: faker.date.past(), 11 | }; 12 | 13 | const position = { top: 0, height: 80 }; 14 | 15 | interface BuildChannel { 16 | [key: string]: any; 17 | } 18 | export function buildChannel(overrides: BuildChannel = {}) { 19 | return { ...channelFakeData, ...overrides }; 20 | } 21 | 22 | export function buildChannelWithPosition(overrides: BuildChannel = {}) { 23 | return { ...channelFakeData, position, ...overrides }; 24 | } 25 | -------------------------------------------------------------------------------- /src/Epg/test/db/epg.ts: -------------------------------------------------------------------------------- 1 | import faker from "@faker-js/faker"; 2 | 3 | export const sinceAndTillTimes = [ 4 | { since: "2022-03-13T23:50:00", till: "2022-03-14T00:55:00" }, 5 | { since: "2022-03-14T00:55:00", till: "2022-03-14T02:35:00" }, 6 | { since: "2022-03-14T02:35:00", till: "2022-03-14T03:30:00" }, 7 | { since: "2022-03-14T03:30:00", till: "2022-03-14T04:15:00" }, 8 | { since: "2022-03-14T04:15:00", till: "2022-03-14T04:30:00" }, 9 | { since: "2022-03-14T04:30:00", till: "2022-03-14T05:10:00" }, 10 | { since: "2022-03-14T05:10:00", till: "2022-03-14T05:35:00" }, 11 | { since: "2022-03-14T05:35:00", till: "2022-03-14T06:00:00" }, 12 | { since: "2022-03-14T06:00:00", till: "2022-03-14T07:00:00" }, 13 | { since: "2022-03-14T07:00:00", till: "2022-03-14T07:40:00" }, 14 | { since: "2022-03-14T07:40:00", till: "2022-03-14T08:05:00" }, 15 | { since: "2022-03-14T08:05:00", till: "2022-03-14T08:20:00" }, 16 | { since: "2022-03-14T08:20:00", till: "2022-03-14T08:45:00" }, 17 | { since: "2022-03-14T08:45:00", till: "2022-03-14T09:15:00" }, 18 | { since: "2022-03-14T09:15:00", till: "2022-03-14T10:20:00" }, 19 | { since: "2022-03-14T10:20:00", till: "2022-03-14T11:15:00" }, 20 | { since: "2022-03-14T11:15:00", till: "2022-03-14T11:50:00" }, 21 | { since: "2022-03-14T11:50:00", till: "2022-03-14T13:45:00" }, 22 | { since: "2022-03-14T13:45:00", till: "2022-03-14T13:55:00" }, 23 | { since: "2022-03-14T13:55:00", till: "2022-03-14T14:55:00" }, 24 | { since: "2022-03-14T14:55:00", till: "2022-03-14T15:55:00" }, 25 | { since: "2022-03-14T15:55:00", till: "2022-03-14T17:00:00" }, 26 | { since: "2022-03-14T17:00:00", till: "2022-03-14T17:20:00" }, 27 | { since: "2022-03-14T17:20:00", till: "2022-03-14T17:30:00" }, 28 | { since: "2022-03-14T17:30:00", till: "2022-03-14T18:30:00" }, 29 | { since: "2022-03-14T18:30:00", till: "2022-03-14T19:25:00" }, 30 | { since: "2022-03-14T19:25:00", till: "2022-03-14T19:30:00" }, 31 | { since: "2022-03-14T19:30:00", till: "2022-03-14T20:05:00" }, 32 | { since: "2022-03-14T20:05:00", till: "2022-03-14T20:10:00" }, 33 | { since: "2022-03-14T20:10:00", till: "2022-03-14T20:35:00" }, 34 | ]; 35 | 36 | const programFakeData = { 37 | id: "36f", 38 | description: faker.lorem.paragraph(), 39 | title: faker.commerce.product(), 40 | since: sinceAndTillTimes[0].since, 41 | till: sinceAndTillTimes[0].till, 42 | channelUuid: "09-3de-34", 43 | image: faker.image.image(), 44 | country: faker.address.country(), 45 | genre: faker.lorem.word(), 46 | rating: faker.datatype.float(), 47 | }; 48 | 49 | interface BuildProgram { 50 | [key: string]: any; 51 | } 52 | export function buildProgram(overrides: BuildProgram = {}) { 53 | return { ...programFakeData, ...overrides }; 54 | } 55 | 56 | export interface BuildProgramWithPosition { 57 | overrides?: BuildProgram; 58 | program?: BuildProgram; 59 | } 60 | export function buildProgramWithPosition({ 61 | overrides, 62 | program, 63 | }: BuildProgramWithPosition = {}) { 64 | return { 65 | data: { ...programFakeData, ...program }, 66 | position: { 67 | height: faker.datatype.float(), 68 | left: faker.datatype.float(), 69 | top: faker.datatype.float(), 70 | width: faker.datatype.float(), 71 | ...overrides, 72 | }, 73 | }; 74 | } 75 | 76 | export function buildEpgWithPosition() { 77 | return sinceAndTillTimes.map((time) => { 78 | const { since, till } = time; 79 | return { 80 | data: { 81 | id: faker.datatype.uuid(), 82 | description: faker.lorem.paragraph(), 83 | title: faker.commerce.product(), 84 | channelUuid: "09-3de-34", 85 | image: faker.image.image(), 86 | country: faker.address.country(), 87 | genre: faker.lorem.word(), 88 | rating: faker.datatype.float(), 89 | since, 90 | till, 91 | }, 92 | position: { 93 | height: faker.datatype.float(), 94 | left: faker.datatype.float(), 95 | top: faker.datatype.float(), 96 | width: faker.datatype.float(), 97 | }, 98 | }; 99 | }); 100 | } 101 | -------------------------------------------------------------------------------- /src/Epg/test/db/index.ts: -------------------------------------------------------------------------------- 1 | export * from './channels'; 2 | export * from './epg'; 3 | -------------------------------------------------------------------------------- /src/Epg/test/helpers.ts: -------------------------------------------------------------------------------- 1 | import { buildChannelWithPosition, buildEpgWithPosition } from "./db"; 2 | 3 | type Overrides = { [key: string]: any }; 4 | export function getLayoutProps( 5 | overrides: Overrides = {}, 6 | sliceNumber: number = 1 7 | ) { 8 | const channels = [buildChannelWithPosition()]; 9 | const programs = buildEpgWithPosition().slice(0, sliceNumber); 10 | 11 | return { 12 | programs, 13 | channels, 14 | scrollY: 0, 15 | startDate: "2022-03-23T00:00:00", 16 | endDate: "2022-03-23T23:59:00", 17 | dayWidth: 7200, 18 | hourWidth: 300, 19 | numberOfHoursInDay: 24, 20 | offsetStartHoursRange: 0, 21 | sidebarWidth: 100, 22 | itemHeight: 80, 23 | isSidebar: true, 24 | isTimeline: true, 25 | isLine: true, 26 | isBaseTimeFormat: false, 27 | isProgramVisible: () => true, 28 | isChannelVisible: () => true, 29 | onScroll: () => {}, 30 | ...overrides, 31 | }; 32 | } 33 | 34 | export const getTestTimeDate = ( 35 | h: string = "00", 36 | m: string = "00", 37 | s: string = "00" 38 | ) => `2022-03-23T${h}:${m}:${s}`; 39 | -------------------------------------------------------------------------------- /src/Epg/test/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./db"; 2 | export * from "./helpers"; 3 | -------------------------------------------------------------------------------- /src/Epg/test/test-utils.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, RenderOptions } from "@testing-library/react"; 3 | import { ThemeProvider, Global } from "@emotion/react"; 4 | import userEvent from "@testing-library/user-event"; 5 | 6 | // Import theme 7 | import { theme } from "../theme"; 8 | 9 | // Import styles 10 | import { globalStyles } from "../styles"; 11 | 12 | interface AllTheProvidersProps { 13 | children: React.ReactNode; 14 | } 15 | const AllTheProviders = ({ children }: AllTheProvidersProps) => { 16 | return ( 17 | 18 | 19 | {children} 20 | 21 | ); 22 | }; 23 | 24 | const customRender = ( 25 | ui: React.ReactElement, 26 | options?: Omit 27 | ) => render(ui, { wrapper: AllTheProviders as React.FC, ...options }); 28 | 29 | export * from "@testing-library/react"; 30 | export { customRender as render, userEvent }; 31 | -------------------------------------------------------------------------------- /src/Epg/theme/emotion.d.ts: -------------------------------------------------------------------------------- 1 | import "@emotion/react"; 2 | import { Theme as CustomTheme } from "../helpers/interfaces"; 3 | 4 | declare module "@emotion/react" { 5 | export interface Theme extends CustomTheme {} 6 | } 7 | -------------------------------------------------------------------------------- /src/Epg/theme/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./theme"; 2 | -------------------------------------------------------------------------------- /src/Epg/theme/theme.ts: -------------------------------------------------------------------------------- 1 | export const theme = { 2 | primary: { 3 | 600: "#1a202c", 4 | 900: "#171923", 5 | }, 6 | grey: { 300: "#d1d1d1" }, 7 | white: "#fff", 8 | green: { 9 | 300: "#2C7A7B", 10 | }, 11 | loader: { 12 | teal: "#5DDADB", 13 | purple: "#3437A2", 14 | pink: "#F78EB6", 15 | bg: "#171923db", 16 | }, 17 | scrollbar: { 18 | border: "#ffffff", 19 | thumb: { 20 | bg: "#e1e1e1", 21 | }, 22 | }, 23 | 24 | gradient: { 25 | blue: { 26 | 300: "#002eb3", 27 | 600: "#002360", 28 | 900: "#051937", 29 | }, 30 | }, 31 | 32 | text: { 33 | grey: { 34 | 300: "#a0aec0", 35 | 500: "#718096", 36 | }, 37 | }, 38 | 39 | timeline: { 40 | divider: { 41 | bg: "#718096", 42 | }, 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Epg"; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "lib": ["dom", "esnext"], 5 | "importHelpers": true, 6 | "declaration": true, 7 | "sourceMap": true, 8 | "rootDir": "./src", 9 | "strict": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "moduleResolution": "node", 15 | "jsx": "react", 16 | "esModuleInterop": true, 17 | "skipLibCheck": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "noEmit": true 20 | }, 21 | "include": ["src"] 22 | } 23 | --------------------------------------------------------------------------------