) => {
56 | setRankCountry(event.target.value);
57 | getRank();
58 | };
59 |
60 | const renderFavorites = () => {
61 | if (!favorites?.length) return null;
62 |
63 | return (
64 |
65 |
76 |
77 |
78 |
79 | Favorites
80 |
81 |
82 |
83 |
84 | {favorites.map((collection) => (
85 |
86 | ))}
87 |
88 |
89 | );
90 | };
91 |
92 | const renderRank = () => {
93 | switch (rankStatus) {
94 | case 'fetching': {
95 | return ;
96 | }
97 |
98 | case 'empty': {
99 | return ;
100 | }
101 |
102 | case 'error': {
103 | return ;
104 | }
105 |
106 | case 'success': {
107 | if (rank?.length) {
108 | return (
109 |
110 | {rank.map((collection, index) => (
111 |
116 | ))}
117 |
118 | );
119 | }
120 | }
121 |
122 | default: {
123 | return null;
124 | }
125 | }
126 | };
127 |
128 | useEffect(() => {
129 | loadStoredData();
130 | if (!rank?.length) getRank();
131 | }, []);
132 |
133 | return (
134 |
135 |
136 |
PodJS
137 |
138 |
139 |
140 |
141 |
142 |
151 |
160 |
161 |
162 |
163 |
164 |
165 |
174 |
179 | {renderFavorites()}
180 |
186 |
199 |
200 |
201 |
202 | Ranking
203 |
204 |
205 | }
208 | size='sm'
209 | variant='ghost'
210 | onClick={onOpen}
211 | />
212 |
219 |
220 |
221 | Ranking settings
222 |
223 |
224 |
225 |
237 |
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 | {renderRank()}
246 |
247 |
248 |
249 |
250 |
251 | );
252 | };
253 |
254 | export default observer(Home);
255 |
--------------------------------------------------------------------------------
/src/pages/search/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Breadcrumb,
3 | BreadcrumbItem,
4 | BreadcrumbLink,
5 | Container,
6 | Flex,
7 | Icon,
8 | SimpleGrid,
9 | Text,
10 | useColorMode,
11 | } from '@chakra-ui/react';
12 | import { observer } from 'mobx-react';
13 | import type { NextPage } from 'next';
14 | import Head from 'next/head';
15 | import { useRouter } from 'next/router';
16 | import { RiArrowLeftSLine, RiHomeLine, RiSearchLine } from 'react-icons/ri';
17 |
18 | import CollectionListItem from '../../components/CollectionListItem';
19 | import EmptyState from '../../components/EmptyState';
20 | import Loader from '../../components/Loader';
21 | import Search from '../../components/Search';
22 | import countries from '../../constants/countries';
23 | import { useStore } from '../../hooks';
24 |
25 | const SearchScreen: NextPage = () => {
26 | const router = useRouter();
27 | const { collectionStore } = useStore();
28 | const { colorMode } = useColorMode();
29 |
30 | const { list, listStatus, searchTerm, searchCountry, getList } = collectionStore;
31 |
32 | const renderList = () => {
33 | let listContent = null;
34 | const country = countries.find(({ code }) => code.toLocaleLowerCase() === searchCountry);
35 |
36 | switch (listStatus) {
37 | case 'fetching': {
38 | listContent = ;
39 | break;
40 | }
41 |
42 | case 'empty': {
43 | listContent = (
44 |
54 | );
55 | break;
56 | }
57 |
58 | case 'error': {
59 | listContent = ;
60 | break;
61 | }
62 |
63 | case 'success': {
64 | if (list?.length) {
65 | listContent = list.map((collection) => (
66 |
67 | ));
68 | } else {
69 | listContent = (
70 |
80 | );
81 | }
82 | break;
83 | }
84 |
85 | default: {
86 | listContent = (
87 |
97 | );
98 | break;
99 | }
100 | }
101 |
102 | return (
103 |
104 | {!!searchTerm.trim().length && (
105 |
117 |
118 |
119 | {`${!!list?.length ? `${list.length} ` : ''}Search results for "${searchTerm}"${
120 | country ? ` in ${country.name}` : ''
121 | }:`}
122 |
123 |
124 | )}
125 |
126 | {listContent}
127 |
128 |
129 | );
130 | };
131 |
132 | return (
133 |
134 |
135 |
PodJS
136 |
137 |
138 |
139 |
140 |
141 |
151 |
160 |
161 |
162 | }
164 | sx={{
165 | 'span, ol': {
166 | display: 'flex',
167 | alignItems: 'center',
168 | },
169 | }}
170 | >
171 |
172 | router.push('/')}>
173 |
174 |
175 | Home
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 | Search
184 |
185 |
186 |
187 |
188 |
189 |
190 |
196 |
197 |
198 |
199 |
200 |
209 | {renderList()}
210 |
211 |
212 |
213 | );
214 | };
215 |
216 | export default observer(SearchScreen);
217 |
--------------------------------------------------------------------------------
/src/services/api.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | const baseURL = 'https://itunes.apple.com/';
4 |
5 | const api = axios.create({
6 | baseURL,
7 | timeout: 20000,
8 | });
9 |
10 | export default api;
11 |
--------------------------------------------------------------------------------
/src/stores/collectionStore.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { makeAutoObservable } from 'mobx';
3 | import { toast } from 'react-toastify';
4 |
5 | import { ERROR_STATE } from '../constants/message';
6 | import { normalizeString } from '../utils';
7 | import type RootStore from './rootStore';
8 |
9 | export default class CollectionStore {
10 | rootStore: RootStore;
11 | list: Collection[] | null = null;
12 | rank: Collection[] | null = null;
13 | favorites: Collection[] = [];
14 | detail: Collection | null = null;
15 | detailSearchResult: Podcast[] | null = null;
16 | listStatus: FetchStatus = 'idle';
17 | rankStatus: FetchStatus = 'idle';
18 | detailStatus: FetchStatus = 'idle';
19 | searchTerm: string = '';
20 | searchCountry: string = '';
21 | rankCountry: string = '';
22 |
23 | constructor(rootStore: RootStore) {
24 | makeAutoObservable(this, { rootStore: false });
25 | this.rootStore = rootStore;
26 | }
27 |
28 | setDetail = (detail?: Collection): void => {
29 | this.detail = detail || null;
30 | };
31 |
32 | setList = (list?: Collection[]): void => {
33 | this.list = list || null;
34 | };
35 |
36 | setListStatus = (status?: FetchStatus): void => {
37 | this.listStatus = status || 'idle';
38 | };
39 |
40 | setRankStatus = (status?: FetchStatus): void => {
41 | this.rankStatus = status || 'idle';
42 | };
43 |
44 | setDetailStatus = (status?: FetchStatus): void => {
45 | this.detailStatus = status || 'idle';
46 | };
47 |
48 | setSearchTerm = (term?: string): void => {
49 | this.searchTerm = term || '';
50 | };
51 |
52 | setSearchCountry = (country?: string): void => {
53 | this.searchCountry = country || '';
54 | };
55 |
56 | setRankCountry = (country?: string): void => {
57 | this.rankCountry = country || '';
58 | this.storeRankCountry();
59 | };
60 |
61 | setRank = (rank?: Collection[]): void => {
62 | this.rank = rank || null;
63 | };
64 |
65 | setFavorites = (favorites?: Collection[]): void => {
66 | this.favorites = favorites || [];
67 | this.storeFavorites();
68 | };
69 |
70 | setDetailSearchResult = (detailSearchResult?: Podcast[]): void => {
71 | this.detailSearchResult = detailSearchResult || [];
72 | };
73 |
74 | storeFavorites = (): void => {
75 | localStorage.setItem('favorites', JSON.stringify(this.favorites));
76 | };
77 |
78 | storeRankCountry = (): void => {
79 | localStorage.setItem('rankCountry', this.rankCountry);
80 | };
81 |
82 | addCollectionToFavorites = (collection: Collection): void => {
83 | if (!this.favorites?.find(({ collectionId }) => collectionId === collection.collectionId)) {
84 | this.setFavorites([...this.favorites, collection]);
85 | }
86 | };
87 |
88 | removeCollectionFromFavorites = (collection: Collection): void => {
89 | const newFavorites = this.favorites.filter(
90 | ({ collectionId }) => collectionId !== collection.collectionId,
91 | );
92 |
93 | this.setFavorites(newFavorites);
94 | };
95 |
96 | getList = async (payload: {
97 | term: string;
98 | country: string;
99 | }): Promise => {
100 | try {
101 | const { term, country } = payload;
102 |
103 | if (this.list?.length && term === this.searchTerm && country === this.searchCountry) {
104 | return;
105 | }
106 |
107 | this.setListStatus('fetching');
108 | this.setList();
109 |
110 | const params = {} as { term: string; country: string };
111 |
112 | if (term?.length) {
113 | this.setSearchTerm(term);
114 | params.term = term;
115 | } else {
116 | this.setSearchTerm();
117 | }
118 |
119 | if (country?.length) {
120 | this.setSearchCountry(country);
121 | params.country = country;
122 | } else {
123 | this.setSearchCountry();
124 | }
125 |
126 | const response = await axios.get('/api/collections', {
127 | params,
128 | });
129 |
130 | const { status, data } = response as { status: number; data: Collection[] };
131 |
132 | if (status === 200 && !data?.length) {
133 | this.setListStatus('empty');
134 |
135 | return {
136 | status: status || 400,
137 | };
138 | }
139 |
140 | if (status !== 200 || !data) {
141 | this.setListStatus('error');
142 |
143 | return {
144 | status: status || 400,
145 | };
146 | }
147 |
148 | this.setList(data);
149 | this.setListStatus('success');
150 |
151 | return { status };
152 | } catch (error) {
153 | console.warn(error);
154 |
155 | toast.error(ERROR_STATE);
156 |
157 | this.setListStatus('error');
158 |
159 | return {
160 | status: 400,
161 | };
162 | }
163 | };
164 |
165 | getRank = async (): Promise => {
166 | try {
167 | this.setRankStatus('fetching');
168 | this.setRank();
169 |
170 | const params = {
171 | country: this.rankCountry,
172 | } as { country: string };
173 |
174 | const response = await axios.get('/api/collections/rank', {
175 | params,
176 | });
177 |
178 | const { status, data } = response as { status: number; data: Collection[] };
179 |
180 | if (status === 200 && !data?.length) {
181 | this.setRankStatus('empty');
182 |
183 | return {
184 | status: status || 400,
185 | };
186 | }
187 |
188 | if (status !== 200 || !data) {
189 | this.setRankStatus('error');
190 |
191 | return {
192 | status: status || 400,
193 | };
194 | }
195 |
196 | this.setRank(data);
197 | this.setRankStatus('success');
198 |
199 | return { status };
200 | } catch (error) {
201 | console.warn(error);
202 |
203 | toast.error(ERROR_STATE);
204 |
205 | this.setRankStatus('error');
206 |
207 | return {
208 | status: 400,
209 | };
210 | }
211 | };
212 |
213 | getDetail = async (payload: { id: string | string[] }): Promise => {
214 | try {
215 | this.setDetailStatus('fetching');
216 | this.setDetail();
217 | this.setDetailSearchResult();
218 |
219 | const { id } = payload;
220 |
221 | const response = await axios.get(`/api/collections/${id}`);
222 |
223 | const { status, data } = response;
224 |
225 | if (status !== 200 || !data) {
226 | this.setDetailStatus('error');
227 |
228 | return {
229 | status: status || 400,
230 | };
231 | }
232 |
233 | this.setDetail(data);
234 | this.setDetailSearchResult(data.items);
235 | this.setDetailStatus('success');
236 |
237 | return { status };
238 | } catch (error) {
239 | console.warn(error);
240 |
241 | toast.error(ERROR_STATE);
242 |
243 | this.setDetailStatus('error');
244 |
245 | return {
246 | status: 400,
247 | };
248 | }
249 | };
250 |
251 | search = (payload: { term?: string }): void => {
252 | const { term } = payload;
253 | this.setDetailSearchResult();
254 |
255 | if (!this.detail?.items) return;
256 |
257 | if (term?.length) {
258 | this.setDetailSearchResult(
259 | this.detail.items.filter((item) =>
260 | normalizeString(item.title.toLowerCase()).includes(
261 | normalizeString(term.toLowerCase()),
262 | ),
263 | ),
264 | );
265 |
266 | return;
267 | }
268 |
269 | this.setDetailSearchResult(this.detail.items);
270 | };
271 |
272 | loadStoredData = (): void => {
273 | const storedFavorites = localStorage.getItem('favorites') || '[]';
274 | const parsedStoredFavorites: Collection[] = JSON.parse(storedFavorites);
275 |
276 | const storedRankCountry = localStorage.getItem('rankCountry') || 'br';
277 | const parsedStoredRankCountry: string = storedRankCountry;
278 |
279 | if (parsedStoredFavorites?.length) this.setFavorites(parsedStoredFavorites);
280 | if (parsedStoredRankCountry?.length) this.setRankCountry(parsedStoredRankCountry);
281 | };
282 |
283 | reset = (): void => {
284 | this.setList();
285 | this.setRank();
286 | this.setDetail();
287 | this.setDetailSearchResult();
288 | this.setListStatus();
289 | this.setDetailStatus();
290 | this.setSearchTerm();
291 | this.setSearchCountry();
292 | this.setRankStatus();
293 | };
294 | }
295 |
--------------------------------------------------------------------------------
/src/stores/playerStore.ts:
--------------------------------------------------------------------------------
1 | import { makeAutoObservable } from 'mobx';
2 |
3 | import type RootStore from './rootStore';
4 |
5 | export default class PlayerStore {
6 | rootStore: RootStore;
7 | currentPodcast: Podcast | null = null;
8 | playList: Podcast[] = [];
9 |
10 | constructor(rootStore: RootStore) {
11 | makeAutoObservable(this, { rootStore: false });
12 | this.rootStore = rootStore;
13 | }
14 |
15 | setCurrentPodcast = (podcast?: Podcast): void => {
16 | this.currentPodcast = podcast || null;
17 | this.storeCurrentPodcast();
18 | };
19 |
20 | setPlayList = (playlist?: Podcast[]): void => {
21 | this.playList = playlist || [];
22 | this.storePlaylist();
23 | };
24 |
25 | addPodcastToPlayList = (podcast: Podcast): void => {
26 | if (!this.playList.find(({ enclosure }) => enclosure.url === podcast?.enclosure?.url)) {
27 | this.setPlayList([...this.playList, podcast]);
28 | }
29 | };
30 |
31 | removePodcastFromPlaylist = (podcast: Podcast): void => {
32 | const newPlaylist = this.playList.filter(
33 | ({ enclosure }) => enclosure.url !== podcast?.enclosure?.url,
34 | );
35 |
36 | if (!newPlaylist.length || podcast.enclosure.url === this.currentPodcast?.enclosure.url) {
37 | this.setCurrentPodcast();
38 |
39 | const audio = document.querySelector('audio');
40 |
41 | if (audio) {
42 | audio.src = '';
43 | audio.currentTime = 0;
44 | audio.pause();
45 | }
46 | }
47 |
48 | this.setPlayList(newPlaylist);
49 | };
50 |
51 | next = (): void => {
52 | let continueLoop = true;
53 |
54 | this.playList.forEach((podcast, index) => {
55 | const nextPodcast = this.playList[index + 1];
56 |
57 | if (
58 | continueLoop &&
59 | nextPodcast &&
60 | podcast.enclosure.url === this.currentPodcast?.enclosure.url
61 | ) {
62 | this.setCurrentPodcast(nextPodcast);
63 |
64 | continueLoop = false;
65 | }
66 | });
67 | };
68 |
69 | previous = (): void => {
70 | this.playList.forEach((podcast, index) => {
71 | const previousPodcast = this.playList[index - 1];
72 |
73 | if (previousPodcast && podcast.enclosure.url === this.currentPodcast?.enclosure.url) {
74 | this.setCurrentPodcast(previousPodcast);
75 | }
76 | });
77 | };
78 |
79 | storePlaylist = (): void => {
80 | localStorage.setItem('playList', JSON.stringify(this.playList));
81 | };
82 |
83 | storeCurrentPodcast = (): void => {
84 | localStorage.setItem('currentPodcast', JSON.stringify(this.currentPodcast));
85 | };
86 |
87 | storeCurrentTime = (time: number): void => {
88 | localStorage.setItem('currentTime', JSON.stringify(time));
89 | };
90 |
91 | loadPlayerData = (): void => {
92 | const storedPlayList = localStorage.getItem('playList') || '[]';
93 | const storedCurrentPodcast = localStorage.getItem('currentPodcast') || 'null';
94 | const storedCurrentTime = localStorage.getItem('currentTime') || '0';
95 |
96 | const parsedStoredPlayList: Podcast[] = JSON.parse(storedPlayList);
97 | const parsedStoredCurrentPodcast: Podcast = JSON.parse(storedCurrentPodcast);
98 |
99 | if (Array.isArray(parsedStoredPlayList) && parsedStoredPlayList.length) {
100 | this.setPlayList(parsedStoredPlayList);
101 | }
102 |
103 | if (parsedStoredCurrentPodcast?.enclosure?.url?.length) {
104 | this.setCurrentPodcast(parsedStoredCurrentPodcast);
105 | }
106 |
107 | if (storedCurrentTime) {
108 | const audio = document.querySelector('audio');
109 | if (audio) audio.currentTime = Number(storedCurrentTime);
110 | }
111 | };
112 |
113 | reset = (): void => {
114 | this.setCurrentPodcast();
115 | this.setPlayList();
116 | };
117 | }
118 |
--------------------------------------------------------------------------------
/src/stores/rootStore.ts:
--------------------------------------------------------------------------------
1 | import { configure } from 'mobx';
2 | import { createContext } from 'react';
3 |
4 | import CollectionStore from './collectionStore';
5 | import PlayerStore from './playerStore';
6 | import UiStore from './uiStore';
7 |
8 | class RootStore {
9 | collectionStore = new CollectionStore(this);
10 | playerStore = new PlayerStore(this);
11 | uiStore = new UiStore(this);
12 |
13 | constructor() {
14 | configure({
15 | enforceActions: 'never',
16 | });
17 | }
18 | }
19 |
20 | export const RootStoreContext = createContext({} as RootStore);
21 |
22 | export const RootStoreProvider = RootStoreContext.Provider;
23 |
24 | export default RootStore;
25 |
--------------------------------------------------------------------------------
/src/stores/uiStore.ts:
--------------------------------------------------------------------------------
1 | import { makeAutoObservable } from 'mobx';
2 |
3 | import type RootStore from './rootStore';
4 |
5 | export default class UiStore {
6 | rootStore: RootStore;
7 | collectionDetailModalIsOpen = false;
8 | playListIsOpen = false;
9 | drawerIsOpen = false;
10 |
11 | constructor(rootStore: RootStore) {
12 | makeAutoObservable(this, { rootStore: false });
13 | this.rootStore = rootStore;
14 | }
15 |
16 | openPlayList = (): void => {
17 | this.playListIsOpen = true;
18 | };
19 |
20 | closePlayList = (): void => {
21 | this.playListIsOpen = false;
22 | };
23 |
24 | openDrawer = (): void => {
25 | this.drawerIsOpen = true;
26 | };
27 |
28 | closeDrawer = (): void => {
29 | this.drawerIsOpen = false;
30 | };
31 |
32 | toggleCollectionModal = ({ open, id }: { open: boolean; id?: string }): void => {
33 | this.collectionDetailModalIsOpen = open;
34 |
35 | if (id) this.rootStore.collectionStore.getDetail({ id });
36 | };
37 |
38 | reset = (): void => {
39 | this.collectionDetailModalIsOpen = false;
40 | this.closePlayList();
41 | };
42 | }
43 |
--------------------------------------------------------------------------------
/src/theme/theme.ts:
--------------------------------------------------------------------------------
1 | import { Input, Select, extendTheme } from '@chakra-ui/react';
2 |
3 | const theme = extendTheme({
4 | config: {
5 | initialColorMode: 'dark',
6 | useSystemColorMode: false,
7 | },
8 | styles: {
9 | global: {
10 | '*': {
11 | boxSizing: 'border-box',
12 | },
13 | html: {
14 | scrollBehavior: 'smooth',
15 | },
16 | 'html, body': {
17 | padding: 0,
18 | margin: 0,
19 | fontFamily:
20 | 'Titillium Web, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
21 | },
22 | a: {
23 | color: 'inherit',
24 | textDecoration: 'none',
25 | },
26 | },
27 | },
28 | colors: {
29 | gray: {
30 | 700: '#161b22',
31 | 800: '#0d1117',
32 | },
33 | },
34 | shadows: { outline: '0 0 0 3px var(--chakra-colors-teal-300)' },
35 | });
36 |
37 | Input.defaultProps = { ...Input.defaultProps, focusBorderColor: 'teal.300' };
38 | Select.defaultProps = { ...Select.defaultProps, focusBorderColor: 'teal.300' };
39 |
40 | export default theme;
41 |
--------------------------------------------------------------------------------
/src/types/global.d.ts:
--------------------------------------------------------------------------------
1 | type FetchStatus = 'idle' | 'fetching' | 'error' | 'empty' | 'success';
2 |
3 | type StoreActionResponse =
4 | | {
5 | status?: number;
6 | data?: any;
7 | message?: string;
8 | }
9 | | AxiosResponse;
10 |
11 | type MessageResponse = { message: string };
12 |
13 | type User = {
14 | id: number;
15 | name: string;
16 | email: string;
17 | };
18 |
19 | type Podcast = {
20 | title: string;
21 | link: string;
22 | isoDate: string;
23 | enclosure: {
24 | url: string;
25 | length: string;
26 | type: string;
27 | };
28 | content: string;
29 | itunes: {
30 | summary: string;
31 | duration: string;
32 | image: string;
33 | };
34 | imageFallback?: string;
35 | };
36 |
37 | type Collection = {
38 | artistName: string;
39 | collectionId: number;
40 | collectionName: string;
41 | artworkUrl100: string;
42 | artworkUrl600: string;
43 | description?: string;
44 | managingEditor: string;
45 | language: string;
46 | copyright: string;
47 | lastBuildDate: string;
48 | primaryGenreName: string;
49 | genres: string[];
50 | feedUrl: string;
51 | trackCount: number;
52 | country: string;
53 | items: Podcast[];
54 | };
55 |
--------------------------------------------------------------------------------
/src/utils/formatDuration.ts:
--------------------------------------------------------------------------------
1 | import formatMillisecondsToHms from './formatMillisecondsToHms';
2 | import formatSecondsToHms from './formatSecondsToHms';
3 |
4 | const formatDuration = (duration: string) => {
5 | try {
6 | if (duration.includes(':')) return duration;
7 |
8 | if (duration.length < 8) return String(formatSecondsToHms(Number(duration)));
9 |
10 | return String(formatMillisecondsToHms(Number(duration)));
11 | } catch (error) {
12 | console.warn('formatDuration ERROR');
13 | console.warn(error);
14 | }
15 | };
16 |
17 | export default formatDuration;
18 |
--------------------------------------------------------------------------------
/src/utils/formatMillisecondsToHms.ts:
--------------------------------------------------------------------------------
1 | const formatMillisecondsToHms = (milliseconds: number) => {
2 | try {
3 | const d = new Date(1000 * Math.round(milliseconds / 1000));
4 | const pad = (i: number) => ('0' + i).slice(-2);
5 | return d.getUTCHours() + ':' + pad(d.getUTCMinutes()) + ':' + pad(d.getUTCSeconds());
6 | } catch (error) {
7 | console.warn('formatMillisecondsToHms ERROR');
8 | return milliseconds;
9 | }
10 | };
11 |
12 | export default formatMillisecondsToHms;
13 |
--------------------------------------------------------------------------------
/src/utils/formatSecondsToHms.ts:
--------------------------------------------------------------------------------
1 | const formatSecondsToHms = (seconds: number) => {
2 | try {
3 | const date = new Date(0);
4 | date.setSeconds(seconds);
5 | return date.toISOString().split('T')[1].split('.')[0];
6 | } catch (error) {
7 | console.warn('formatSecondsToHms ERROR', error);
8 | return seconds;
9 | }
10 | };
11 |
12 | export default formatSecondsToHms;
13 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | import formatDuration from './formatDuration';
2 | import formatMillisecondsToHms from './formatMillisecondsToHms';
3 | import formatSecondsToHms from './formatSecondsToHms';
4 | import normalizeString from './normalizeString';
5 |
6 | export { formatDuration, formatMillisecondsToHms, formatSecondsToHms, normalizeString };
7 |
--------------------------------------------------------------------------------
/src/utils/normalizeString.ts:
--------------------------------------------------------------------------------
1 | const normalizeString = (value: string) => value.normalize('NFD').replace(/[^\w\s]/gi, '');
2 |
3 | export default normalizeString;
4 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true
17 | },
18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
19 | "exclude": ["node_modules"]
20 | }
21 |
--------------------------------------------------------------------------------