Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
{
"extends": "next/core-web-vitals"
"extends": "next/core-web-vitals",
"rules": {
"react-hooks/exhaustive-deps": "off"
}
}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

.idea
*-lock.json
*.lock
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,34 @@
![CloudFlare Pages Orma Carbon](https://img.shields.io/endpoint?url=https://cloudflare-pages-badges.laravieira.workers.dev/?projectName=ormacarbon-challenge)

# Orma Carbon Challenge
Check it out at [https://ormacarbon.laravieira.me/](https://ormacarbon.laravieira.me/).

The idea of this project is a radio list, with the ability to:
- Browser online stations
- See details of each online stations
- Save your favorites station on browser
- Listen to the stations

### What was requested
- [x] Dedicated branch for project idea
- [x] Used [radio-browser-api](https://github.com/ivandotv/radio-browser-api) API listed on [public-apis](https://github.com/public-apis/public-apis)
- [x] Used Next.js features
- [x] Used styled components with [MUI Kit](https://mui.com/)
- [x] Page fully responvise
- [x] Persistent dark/light mode, default per system, overwrite switch.
- [x] Use of states with `useState` hook
- [x] Use of contexts with `createContext` and `useContext` hooks
- [x] Multiple pages (home, favorites, dynamic details)
- [x] Navbar with links and pagination for navigation
- [x] API with listing pagination with 15 itens per page
- [x] Project completely made in English
- [x] Project deployed (done with [Cloudflare Pages](https://pages.cloudflare.com/))

### How to run locally
```shell
npm run dev
```

# **TESTE DE FRONTEND**

Neste teste, você será livre para criar uma aplicação consumindo a API que você quiser e com o tema que desejar.
Expand Down
32 changes: 32 additions & 0 deletions api/RadioBrowserApi.overwrite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {
RadioBrowserApi as RadioBrowserApiSuper
} from 'radio-browser-api';
import https from 'https';
import axios from 'axios';

type Server = {
ip: string,
name: string
};

class RadioBrowserApi extends RadioBrowserApiSuper {
constructor(appName: string, hideBroken = true) {
super(appName, hideBroken);
}

// temporary fix for https cert error when in frontend hardcode the server
// https://github.com/segler-alex/radiobrowser-api-rust/issues/122
async resolveBaseUrl(config: RequestInit = {}): Promise<Server[]> {
const httpsAgent = new https.Agent({

rejectUnauthorized: false
});
const SERVER_LIST_URL = 'https://de1.api.radio-browser.info/json/servers';

return axios(SERVER_LIST_URL, { httpsAgent })
.then(response => response.status === 200 ? response.data : Promise.reject(response.status))
.then(list => list as Server[])
}
}

export default RadioBrowserApi;
26 changes: 26 additions & 0 deletions components/Empty/Empty.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { IconDeviceAirpods } from '@tabler/icons';
import { Typography } from '@mui/material';
import React from 'react';

type EmptyProps = {
title: string,
caption: string
};

function Empty(props: EmptyProps) {
const { title, caption } = props;

return <div className="mt-10 flex flex-col sm:flex-row sm:items-center gap-4">
<IconDeviceAirpods className="h-20 w-20 text-fuchsia-800 dark:text-fuchsia-500" strokeWidth={.5}/>
<div>
<Typography variant="h5" className="w-full text-fuchsia-700 dark:text-fuchsia-500">
{ title }
</Typography>
<p className="dark:text-gray-300">
{ caption }
</p>
</div>
</div>
}

export default Empty;
3 changes: 3 additions & 0 deletions components/Empty/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Empty from './Empty.component';

export default Empty;
20 changes: 20 additions & 0 deletions components/GoogleMaps/GoogleMaps.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {Wrapper} from '@googlemaps/react-wrapper';
import Map from './Map.component';
import { GOOGLE_MAPS_KEY } from './GoogleMaps.config';

type GoogleMapsType = {
latitude: number,
longitude: number
}

function GoogleMaps(props: GoogleMapsType) {
const { latitude, longitude } = props;

return (
<Wrapper apiKey={ GOOGLE_MAPS_KEY }>
<Map latitude={ latitude } longitude={ longitude } />
</Wrapper>
);
}

export default GoogleMaps;
1 change: 1 addition & 0 deletions components/GoogleMaps/GoogleMaps.config.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const GOOGLE_MAPS_KEY = 'AIzaSyDxWJ-sXtz9Iab3vHaxdkMwiQel_mctH-A';
33 changes: 33 additions & 0 deletions components/GoogleMaps/Map.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createRef, useEffect, useState } from 'react';

type MapProps = {
latitude: number,
longitude: number
}

function Map(props: MapProps) {
const { latitude, longitude } = props;
const [map, setMap] = useState<google.maps.Map|null>(null);
const ref = createRef<HTMLDivElement>();

useEffect(() => {
if (ref.current && !map) {
setMap(
new google.maps.Map(ref.current, {
zoomControl: true,
mapTypeControl: false,
streetViewControl: false,
center: {
lat: latitude ?? 0,
lng: longitude ?? 0,
},
zoom: 10,
})
);
}
}, [ref, map, latitude, longitude]);

return <div ref={ref} className="w-full h-full"/>;
}

export default Map;
3 changes: 3 additions & 0 deletions components/GoogleMaps/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import GoogleMaps from './GoogleMaps.component';

export default GoogleMaps;
95 changes: 95 additions & 0 deletions components/Navbar/Navbar.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { AppBar, IconButton, Pagination, Toolbar } from '@mui/material';
import { IconHeart, IconHome2, IconMoon, IconPlayerPause, IconPlayerPlay, IconSun } from '@tabler/icons';
import React, { useContext } from 'react';
import { ThemeContextType, ThemeContext } from '../../contexts/Theme/Theme.context';
import RadioContext, { RadioContextType } from '../../contexts/Radio/Radio.context';
import Link from 'next/link';
import PlayerContext, { PlayerContextType } from '../../contexts/Player/Player.context';

function Navbar() {
const themeContext = useContext<ThemeContextType>(ThemeContext);
const pageContext = useContext<RadioContextType>(RadioContext);
const playerContext = useContext<PlayerContextType>(PlayerContext);

function renderPlayButton() {
const { current, playing, play, pause } = playerContext;

return <IconButton aria-label={ playing ? 'Pause station' : 'Resume station' }
className={ `transition-all h-10 w-10 bg-fuchsia-600 ${ current ? '' : 'hidden' }` }
onClick={ () => playing ? pause() : (current ? play(current) : null) }>
{
playing
? <IconPlayerPause strokeWidth={1} className="text-gray-800 dark:text-fuchsia-200"/>
: <IconPlayerPlay strokeWidth={1} className="text-gray-800 dark:text-fuchsia-200"/>
}
</IconButton>;
}

function renderNavDesktop() {
return <div className="hidden top-4 sticky sm:flex flex-col items-center gap-4">
<div className="flex flex-col gap-y-4 bg-fuchsia-400 dark:bg-fuchsia-600 rounded-full py-4 px-2">
<Link href="/">
<IconButton aria-label="home page">
<IconHome2 strokeWidth={1} className="text-gray-800 dark:text-fuchsia-200"/>
</IconButton>
</Link>
<Link href="/favorites">
<IconButton aria-label="my favorites page">
<IconHeart strokeWidth={1} className="text-gray-800 dark:text-fuchsia-200"/>
</IconButton>
</Link>
<IconButton aria-label="switch dark/light mode" onClick={themeContext.toggle}>
{ themeContext.isDark
? <IconSun strokeWidth={1} className="text-gray-800 dark:text-fuchsia-200"/>
: <IconMoon strokeWidth={1} className="text-gray-800 dark:text-fuchsia-200"/>
}
</IconButton>
</div>
{ renderPlayButton() }
<Pagination count={ 35700/15 }
page={ pageContext.page }
onChange={ pageContext.setPage }
className="mx-auto w-min" />
</div>;
}

function renderNavMobile() {
return <AppBar className="fixed top-auto bottom-0 sm:hidden bg-fuchsia-400 dark:bg-fuchsia-600">
<Toolbar className="flex justify-evenly">
<Link href="/">
<IconButton aria-label="home page">
<IconHome2 strokeWidth={1} className="text-gray-800 dark:text-fuchsia-200"/>
</IconButton>
</Link>
<Link href="/favorites">
<IconButton aria-label="my favorites page">
<IconHeart strokeWidth={1} className="text-gray-800 dark:text-fuchsia-200"/>
</IconButton>
</Link>
<IconButton aria-label="switch dark/light mode" onClick={themeContext.toggle}>
{ themeContext.isDark
? <IconSun strokeWidth={1} className="text-gray-800 dark:text-fuchsia-200"/>
: <IconMoon strokeWidth={1} className="text-gray-800 dark:text-fuchsia-200"/>
}
</IconButton>
{ renderPlayButton() }
</Toolbar>
<Pagination count={ 35700/15 }
page={ pageContext.page }
onChange={ pageContext.setPage }
size="small"
className="mb-2 mx-auto sm:hidden" />
<Pagination count={ 35700/15 }
page={ pageContext.page }
onChange={ pageContext.setPage }
className="mb-2 mx-auto hidden sm:block" />
</AppBar>;
}

return <div className="self-stretch relative">
{ renderNavDesktop() }
{ renderNavMobile() }
</div>;
}

export default Navbar;
3 changes: 3 additions & 0 deletions components/Navbar/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Navbar from './Navbar.component';

export default Navbar;
12 changes: 12 additions & 0 deletions components/PageContainer/PageContainer.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { PropsWithChildren } from 'react';
import { Container } from '@mui/material';

function PageContainer(props: PropsWithChildren) {
return <div className="transition-colors duration-500 bg-neutral-300 dark:bg-neutral-800">
<Container maxWidth="md" className="flex sm:gap-8 min-h-screen pt-16 pb-20">
{ props.children }
</Container>;
</div>
}

export default PageContainer;
3 changes: 3 additions & 0 deletions components/PageContainer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import PageContainer from './PageContainer.component';

export default PageContainer;
70 changes: 70 additions & 0 deletions components/Station/Station.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Station } from 'radio-browser-api';
import {
IconRadio,
IconStars,
IconWaveSquare
} from '@tabler/icons';
import { LinearProgress, Typography } from '@mui/material';
import { useContext } from 'react';
import PlayerContext from '../../contexts/Player';
import { PlayerContextType } from '../../contexts/Player/Player.context';
import StationControls from '../StationControls';

function Station(props: Station) {
const {
name,
bitrate,
votes,
favicon,
id
} = props;

const { loading, current } = useContext<PlayerContextType>(PlayerContext);

function renderFavIcon() {
return <div className="basis-10 flex-shrink-0 h-10 mt-3 sm:mt-0 flex justify-center items-center">
{
favicon
// The images sources are not consistent for this to work
// eslint-disable-next-line @next/next/no-img-element
? <img src={favicon} alt="favicon" aria-hidden className="w-full"/>
: <IconRadio className="text-fuchsia-600 w-8 h-8"/>
}
</div>;
}

function renderLoading() {
if(current && current.id !== id || !loading)
return <div className="h-1"/>;
return <div className="h-1">
<LinearProgress className="bg-fuchsia-600 w-full h-1"/>
</div>;
}

return <li className="flex flex-col w-full border-fuchsia-500 dark:border-fuchsia-900 border-b-[1px]">
<div className="flex flex-col sm:flex-row w-full sm:justify-between py-6">
<div className="w-full flex gap-4 truncate sm:items-center justify-self-stretch mb-2 sm:mb-0">
{ renderFavIcon() }
<div className="truncate">
<Typography variant="h6" className="text-fuchsia-600 dark:text-fuchsia-300 truncate max-w-full">{ name.trim().length > 1 ? name.trim() : 'Unnamed' }</Typography>
<div className="flex gap-3">
<div className="flex gap-1 text-gray-800 dark:text-gray-300">
<IconStars className="w-5"/> { votes }
</div>
{
bitrate > 0
? <div className="flex gap-1 text-gray-800 dark:text-gray-300">
<IconWaveSquare className="w-5"/> { bitrate }
</div>
: <></>
}
</div>
</div>
</div>
<StationControls { ...props }/>
</div>
{ renderLoading() }
</li>;
}

export default Station;
3 changes: 3 additions & 0 deletions components/Station/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Station from './Station.component';

export default Station;
Loading