- Add MBID/Spotify Autolinking if track exists
- Add Genre table + .go files
This commit is contained in:
Daniel Mason 2021-04-03 21:29:31 +13:00
parent 8324894b0f
commit 1c865a6784
Signed by: idanoo
GPG Key ID: 387387CDBC02F132
36 changed files with 318 additions and 79 deletions

View File

@ -3,7 +3,7 @@ stages:
- bundle - bundle
variables: variables:
VERSION: 0.0.17 VERSION: 0.0.18
build-go: build-go:
image: golang:1.16.2 image: golang:1.16.2

View File

@ -1,3 +1,7 @@
# 0.0.18
- Add MBID/Spotify Autolinking if track exists
- Add Genre table + .go files
# 0.0.17 # 0.0.17
- Add check for registration_enabled on /register endpoint - Add check for registration_enabled on /register endpoint
- Made songlookup check artist name as well - Made songlookup check artist name as well

View File

@ -12,8 +12,8 @@ type Album struct {
Name string `json:"name"` Name string `json:"name"`
Desc sql.NullString `json:"desc"` Desc sql.NullString `json:"desc"`
Img sql.NullString `json:"img"` Img sql.NullString `json:"img"`
MusicBrainzID sql.NullString `json:"mbid"` MusicBrainzID string `json:"mbid"`
SpotifyID sql.NullString `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
} }
// insertAlbum - This will return if it exists or create it based on MBID > Name // insertAlbum - This will return if it exists or create it based on MBID > Name
@ -61,6 +61,16 @@ func insertAlbum(name string, mbid string, spotifyId string, artists []string, t
return album, errors.New("Unable to fetch album!") return album, errors.New("Unable to fetch album!")
} }
if album.MusicBrainzID != mbid {
album.MusicBrainzID = mbid
album.updateAlbum("mbid", mbid, tx)
}
if album.SpotifyID != spotifyId {
album.SpotifyID = spotifyId
album.updateAlbum("spotify_id", spotifyId, tx)
}
return album, nil return album, nil
} }
@ -100,8 +110,8 @@ func (album *Album) linkAlbumToArtists(artists []string, tx *sql.Tx) error {
return err return err
} }
func updateAlbum(uuid string, col string, val string, tx *sql.Tx) error { func (album *Album) updateAlbum(col string, val string, tx *sql.Tx) error {
_, err := tx.Exec("UPDATE `albums` SET `"+col+"` = ? WHERE `uuid` = UUID_TO_BIN(?,true)", val, uuid) _, err := tx.Exec("UPDATE `albums` SET `"+col+"` = ? WHERE `uuid` = UUID_TO_BIN(?,true)", val, album.Uuid)
return err return err
} }

View File

@ -11,8 +11,8 @@ type Artist struct {
Name string `json:"name"` Name string `json:"name"`
Desc sql.NullString `json:"desc"` Desc sql.NullString `json:"desc"`
Img sql.NullString `json:"img"` Img sql.NullString `json:"img"`
MusicBrainzID sql.NullString `json:"mbid"` MusicBrainzID string `json:"mbid"`
SpotifyID sql.NullString `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
} }
// insertArtist - This will return if it exists or create it based on MBID > Name // insertArtist - This will return if it exists or create it based on MBID > Name
@ -55,6 +55,16 @@ func insertArtist(name string, mbid string, spotifyId string, tx *sql.Tx) (Artis
return artist, errors.New("Unable to fetch artist!") return artist, errors.New("Unable to fetch artist!")
} }
if artist.MusicBrainzID != mbid {
artist.MusicBrainzID = mbid
artist.updateArtist("mbid", mbid, tx)
}
if artist.SpotifyID != spotifyId {
artist.SpotifyID = spotifyId
artist.updateArtist("spotify_id", spotifyId, tx)
}
return artist, nil return artist, nil
} }
@ -80,8 +90,8 @@ func insertNewArtist(name string, mbid string, spotifyId string, tx *sql.Tx) err
return err return err
} }
func updateArtist(uuid string, col string, val string, tx *sql.Tx) error { func (artist *Artist) updateArtist(col string, val string, tx *sql.Tx) error {
_, err := tx.Exec("UPDATE `artists` SET `"+col+"` = ? WHERE `uuid` = UUID_TO_BIN(?,true)", val, uuid) _, err := tx.Exec("UPDATE `artists` SET `"+col+"` = ? WHERE `uuid` = UUID_TO_BIN(?,true)", val, artist.Uuid)
return err return err
} }

View File

@ -0,0 +1,47 @@
package goscrobble
import (
"database/sql"
"log"
)
type Genre struct {
UUID string `json:"uuid"`
Name string `json:"name"`
}
func getGenre(uuid string) Genre {
var genre Genre
err := db.QueryRow(
"SELECT BIN_TO_UUID(`uuid`, true), `name` FROM `artists` WHERE `uuid` = UUID_TO_BIN(?,true)",
uuid).Scan(&genre.UUID, &genre.Name)
if err != nil {
if err != sql.ErrNoRows {
log.Printf("Error fetching artists: %+v", err)
}
}
return genre
}
func getGenreByName(name string) Genre {
var genre Genre
err := db.QueryRow(
"SELECT BIN_TO_UUID(`uuid`, true), `name` FROM `artists` WHERE `name` = ?",
name).Scan(&genre.UUID, &genre.Name)
if err != nil {
if err != sql.ErrNoRows {
log.Printf("Error fetching artists: %+v", err)
}
}
return genre
}
func (genre *Genre) updateGenreName(name string, value string) error {
_, err := db.Exec("UPDATE `genres` SET `name` = ? WHERE uuid = UUID_TO_BIN(?, true)", name, genre.UUID)
return err
}

View File

@ -252,6 +252,7 @@ func handleIngress(w http.ResponseWriter, r *http.Request, userUuid string) {
err = ParseJellyfinInput(userUuid, jfInput, ip, tx) err = ParseJellyfinInput(userUuid, jfInput, ip, tx)
if err != nil { if err != nil {
fmt.Println(err)
tx.Rollback() tx.Rollback()
throwOkError(w, err.Error()) throwOkError(w, err.Error())
return return
@ -477,7 +478,7 @@ func fetchServerInfo(w http.ResponseWriter, r *http.Request) {
} }
info := ServerInfo{ info := ServerInfo{
Version: "0.0.17", Version: "0.0.18",
RegistrationEnabled: cachedRegistrationEnabled, RegistrationEnabled: cachedRegistrationEnabled,
} }

View File

@ -3,6 +3,7 @@ package goscrobble
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"log" "log"
"strings" "strings"
) )
@ -13,8 +14,8 @@ type Track struct {
Length int `json:"length"` Length int `json:"length"`
Desc sql.NullString `json:"desc"` Desc sql.NullString `json:"desc"`
Img sql.NullString `json:"img"` Img sql.NullString `json:"img"`
MusicBrainzID sql.NullString `json:"mbid"` MusicBrainzID string `json:"mbid"`
SpotifyID sql.NullString `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
} }
// insertTrack - This will return if it exists or create it based on MBID > Name // insertTrack - This will return if it exists or create it based on MBID > Name
@ -24,14 +25,16 @@ func insertTrack(name string, legnth int, mbid string, spotifyId string, album s
// Try locate our track // Try locate our track
if mbid != "" { if mbid != "" {
track = fetchTrack("mbid", mbid, tx) track = fetchTrack("mbid", mbid, tx)
fmt.Printf("Fetech mbid: %s", mbid)
} else if spotifyId != "" { } else if spotifyId != "" {
track = fetchTrack("spotify_id", spotifyId, tx) track = fetchTrack("spotify_id", spotifyId, tx)
fmt.Printf("Fetech spotify: %s", spotifyId)
} }
// If it didn't match above, lookup by name // If it didn't match above, lookup by name
if track.Uuid == "" { if track.Uuid == "" {
// TODO: add artist check here too // TODO: add artist check here too
track = fetchTrackWithArtists(name, artists, tx) track = fetchTrackWithArtists(name, artists, album, tx)
} }
// If we can't find it. Lets add it! // If we can't find it. Lets add it!
@ -49,7 +52,7 @@ func insertTrack(name string, legnth int, mbid string, spotifyId string, album s
} }
if track.Uuid == "" { if track.Uuid == "" {
track = fetchTrackWithArtists(name, artists, tx) track = fetchTrackWithArtists(name, artists, album, tx)
} }
err = track.linkTrack(album, artists, tx) err = track.linkTrack(album, artists, tx)
@ -62,13 +65,23 @@ func insertTrack(name string, legnth int, mbid string, spotifyId string, album s
return track, errors.New("Unable to fetch track!") return track, errors.New("Unable to fetch track!")
} }
if track.MusicBrainzID != mbid {
track.MusicBrainzID = mbid
track.updateTrack("mbid", mbid, tx)
}
if track.SpotifyID != spotifyId {
track.SpotifyID = spotifyId
track.updateTrack("spotify_id", spotifyId, tx)
}
return track, nil return track, nil
} }
func fetchTrack(col string, val string, tx *sql.Tx) Track { func fetchTrack(col string, val string, tx *sql.Tx) Track {
var track Track var track Track
err := tx.QueryRow( err := tx.QueryRow(
"SELECT BIN_TO_UUID(`uuid`, true), `name`, `desc`, `img`, `mbid` FROM `tracks` WHERE `"+col+"` = ?", "SELECT BIN_TO_UUID(`uuid`, true), `name`, `desc`, `img`, `mbid` FROM `tracks` WHERE `"+col+"` = ? LIMIT 1",
val).Scan(&track.Uuid, &track.Name, &track.Desc, &track.Img, &track.MusicBrainzID) val).Scan(&track.Uuid, &track.Name, &track.Desc, &track.Img, &track.MusicBrainzID)
if err != nil { if err != nil {
@ -80,15 +93,16 @@ func fetchTrack(col string, val string, tx *sql.Tx) Track {
return track return track
} }
func fetchTrackWithArtists(name string, artists []string, tx *sql.Tx) Track { func fetchTrackWithArtists(name string, artists []string, album string, tx *sql.Tx) Track {
var track Track var track Track
artistString := strings.Join(artists, "','") artistString := strings.Join(artists, "','")
err := tx.QueryRow( err := tx.QueryRow(
"SELECT BIN_TO_UUID(`uuid`, true), `name`, `desc`, `img`, `mbid` FROM `tracks` "+ "SELECT BIN_TO_UUID(`uuid`, true), `name`, `desc`, `img`, `mbid` FROM `tracks` "+
"LEFT JOIN `track_artist` ON `tracks`.`uuid` = `track_artist`.`track` "+ "LEFT JOIN `track_artist` ON `tracks`.`uuid` = `track_artist`.`track` "+
"WHERE `name` = ? AND BIN_TO_UUID(`track_artist`.`artist`, true) IN ('`"+artistString+"`')", "LEFT JOIN `track_album` ON `tracks`.`uuid` = `track_album`.`track` "+
name).Scan(&track.Uuid, &track.Name, &track.Desc, &track.Img, &track.MusicBrainzID) "WHERE `name` = ? AND BIN_TO_UUID(`track_artist`.`artist`, true) IN ('"+artistString+"') "+
"AND BIN_TO_UUID(`track_album`.`album`,true) = ? LIMIT 1",
name, album).Scan(&track.Uuid, &track.Name, &track.Desc, &track.Img, &track.MusicBrainzID)
if err != nil { if err != nil {
if err != sql.ErrNoRows { if err != sql.ErrNoRows {
@ -138,8 +152,8 @@ func (track Track) linkTrackToArtists(artists []string, tx *sql.Tx) error {
return nil return nil
} }
func updateTrack(uuid string, col string, val string, tx *sql.Tx) error { func (track *Track) updateTrack(col string, val string, tx *sql.Tx) error {
_, err := tx.Exec("UPDATE `tracks` SET `"+col+"` = ? WHERE `uuid` = UUID_TO_BIN(?,true)", val, uuid) _, err := tx.Exec("UPDATE `tracks` SET `"+col+"` = ? WHERE `uuid` = UUID_TO_BIN(?,true)", val, track.Uuid)
return err return err
} }

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS `genres`;

View File

@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS `genres` (
`uuid` BINARY(16) PRIMARY KEY,
`name` VARCHAR(255) NOT NULL,
KEY `nameLookup` (`name`)
) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;

View File

@ -1,7 +1,7 @@
START TRANSACTION; START TRANSACTION;
ALTER TABLE albums ADD COLUMN `mbid` VARCHAR(36) DEFAULT NULL; ALTER TABLE albums ADD COLUMN `mbid` VARCHAR(36) DEFAULT '';
ALTER TABLE artists ADD COLUMN `mbid` VARCHAR(36) DEFAULT NULL; ALTER TABLE artists ADD COLUMN `mbid` VARCHAR(36) DEFAULT '';
ALTER TABLE tracks ADD COLUMN `mbid` VARCHAR(36) DEFAULT NULL; ALTER TABLE tracks ADD COLUMN `mbid` VARCHAR(36) DEFAULT '';
COMMIT; COMMIT;

View File

@ -1,9 +1,9 @@
START TRANSACTION; START TRANSACTION;
ALTER TABLE `users` ADD COLUMN `spotify_id` VARCHAR(255) DEFAULT ''; ALTER TABLE `users` ADD COLUMN `spotify_id` VARCHAR(255) NOT NULL DEFAULT '';
ALTER TABLE `albums` ADD COLUMN `spotify_id` VARCHAR(255) DEFAULT ''; ALTER TABLE `albums` ADD COLUMN `spotify_id` VARCHAR(255) NOT NULL DEFAULT '';
ALTER TABLE `artists` ADD COLUMN `spotify_id` VARCHAR(255) DEFAULT ''; ALTER TABLE `artists` ADD COLUMN `spotify_id` VARCHAR(255) NOT NULL DEFAULT '';
ALTER TABLE `tracks` ADD COLUMN `spotify_id` VARCHAR(255) DEFAULT ''; ALTER TABLE `tracks` ADD COLUMN `spotify_id` VARCHAR(255) NOT NULL DEFAULT '';
ALTER TABLE `users` ADD INDEX `spotifyLookup` (`spotify_id`); ALTER TABLE `users` ADD INDEX `spotifyLookup` (`spotify_id`);
ALTER TABLE `albums` ADD INDEX `spotifyLookup` (`spotify_id`); ALTER TABLE `albums` ADD INDEX `spotifyLookup` (`spotify_id`);

View File

@ -44,6 +44,11 @@ html, body {
color: white; color: white;
} }
.pageBody {
padding: 20px 5px 5px 5px;
font-size: 16pt;
}
.App-link { .App-link {
color: #61dafb; color: #61dafb;
} }

View File

@ -3,6 +3,9 @@ import Home from './Pages/Home';
import About from './Pages/About'; import About from './Pages/About';
import Dashboard from './Pages/Dashboard'; import Dashboard from './Pages/Dashboard';
import Profile from './Pages/Profile'; import Profile from './Pages/Profile';
import Artist from './Pages/Artist';
import Album from './Pages/Album';
import Track from './Pages/Track';
import User from './Pages/User'; import User from './Pages/User';
import Admin from './Pages/Admin'; import Admin from './Pages/Admin';
import Login from './Pages/Login'; import Login from './Pages/Login';
@ -33,6 +36,9 @@ const App = () => {
<Route path="/dashboard" component={Dashboard} /> <Route path="/dashboard" component={Dashboard} />
<Route path="/user" component={User} /> <Route path="/user" component={User} />
<Route path="/u/:uuid" component={Profile} /> <Route path="/u/:uuid" component={Profile} />
<Route path="/artist/:uuid" component={Artist} />
<Route path="/album/:uuid" component={Album} />
<Route path="/track/:uuid" component={Track} />
<Route path="/admin" component={Admin} /> <Route path="/admin" component={Admin} />

View File

@ -1,4 +0,0 @@
.aboutBody {
padding: 20px 0px 0px 0px;
font-size: 16pt;
}

View File

@ -12,7 +12,7 @@ const About = () => {
Used to track your listening history and build a profile to discover new music. Used to track your listening history and build a profile to discover new music.
</p> </p>
<a <a
className="aboutBody" className="pageBody"
href="https://gitlab.com/idanoo/go-scrobble" href="https://gitlab.com/idanoo/go-scrobble"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"

View File

@ -1,9 +1,3 @@
.adminBody {
padding: 20px 5px 5px 5px;
font-size: 16pt;
width: 300px;
}
.adminFields { .adminFields {
width: 100%; width: 100%;
} }

View File

@ -1,7 +1,7 @@
import React, { useContext, useState, useEffect } from 'react'; import React, { useContext, useState, useEffect } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import '../App.css'; import '../App.css';
import './Login.css'; import './Admin.css';
import { Button } from 'reactstrap'; import { Button } from 'reactstrap';
import { Formik, Form, Field } from 'formik'; import { Formik, Form, Field } from 'formik';
import ScaleLoader from 'react-spinners/ScaleLoader'; import ScaleLoader from 'react-spinners/ScaleLoader';
@ -54,7 +54,7 @@ const Admin = () => {
<h1> <h1>
Admin Panel Admin Panel
</h1> </h1>
<div className="loginBody"> <div className="pageBody">
<Formik <Formik
initialValues={configs} initialValues={configs}
onSubmit={(values) => postConfigs(values, toggle)} onSubmit={(values) => postConfigs(values, toggle)}

0
web/src/Pages/Album.css Normal file
View File

59
web/src/Pages/Album.js Normal file
View File

@ -0,0 +1,59 @@
import React, { useState, useEffect } from 'react';
import '../App.css';
import './Album.css';
import ScaleLoader from 'react-spinners/ScaleLoader';
import ScrobbleTable from '../Components/ScrobbleTable'
const Album = (route) => {
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState({});
let album = false;
if (route && route.match && route.match.params && route.match.params.uuid) {
album = route.match.params.uuid;
} else {
album = false;
}
useEffect(() => {
if (!album) {
return false;
}
// getProfile(username)
// .then(data => {
// setProfile(data);
// console.log(data)
// setLoading(false);
// })
}, [album])
if (loading) {
return (
<div className="pageWrapper">
<ScaleLoader color="#6AD7E5" />
</div>
)
}
if (!album || !album) {
return (
<div className="pageWrapper">
Unable to fetch user
</div>
)
}
return (
<div className="pageWrapper">
<h1>
{album}
</h1>
<div className="pageBody">
Album
</div>
</div>
);
}
export default Album;

0
web/src/Pages/Artist.css Normal file
View File

59
web/src/Pages/Artist.js Normal file
View File

@ -0,0 +1,59 @@
import React, { useState, useEffect } from 'react';
import '../App.css';
import './Artist.css';
import ScaleLoader from 'react-spinners/ScaleLoader';
import ScrobbleTable from '../Components/ScrobbleTable'
const Artist = (route) => {
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState({});
let artist = false;
if (route && route.match && route.match.params && route.match.params.uuid) {
artist = route.match.params.uuid;
} else {
artist = false;
}
useEffect(() => {
if (!artist) {
return false;
}
// getProfile(username)
// .then(data => {
// setProfile(data);
// console.log(data)
// setLoading(false);
// })
}, [artist])
if (loading) {
return (
<div className="pageWrapper">
<ScaleLoader color="#6AD7E5" />
</div>
)
}
if (!artist || !artist) {
return (
<div className="pageWrapper">
Unable to fetch user
</div>
)
}
return (
<div className="pageWrapper">
<h1>
{artist}
</h1>
<div className="pageBody">
Artist
</div>
</div>
);
}
export default Artist;

View File

@ -1,4 +0,0 @@
.dashboardBody {
padding: 20px 5px 5px 5px;
font-size: 16pt;
}

View File

@ -42,7 +42,7 @@ const Dashboard = () => {
<h1> <h1>
{user.username}'s Dashboard! {user.username}'s Dashboard!
</h1> </h1>
<div className="dashboardBody"> <div className="pageBody">
{loading {loading
? <ScaleLoader color="#6AD7E5" size={60} /> ? <ScaleLoader color="#6AD7E5" size={60} />
: <ScrobbleTable data={dashboardData.items} /> : <ScrobbleTable data={dashboardData.items} />

View File

@ -1,9 +1,3 @@
.loginBody {
padding: 20px 5px 5px 5px;
font-size: 16pt;
width: 300px;
}
.loginFields { .loginFields {
width: 100%; width: 100%;
} }

View File

@ -25,7 +25,7 @@ const Login = () => {
<h1> <h1>
Login Login
</h1> </h1>
<div className="loginBody"> <div className="pageBody">
<Formik <Formik
initialValues={{ username: '', password: '' }} initialValues={{ username: '', password: '' }}
onSubmit={values => Login(values)} onSubmit={values => Login(values)}

View File

@ -1,4 +0,0 @@
.profileBody {
padding: 20px 5px 5px 5px;
font-size: 16pt;
}

View File

@ -50,7 +50,7 @@ const Profile = (route) => {
<h1> <h1>
{profile.username}'s Profile {profile.username}'s Profile
</h1> </h1>
<div className="profileBody"> <div className="pageBody">
Last 10 scrobbles...<br/> Last 10 scrobbles...<br/>
<ScrobbleTable data={profile.scrobbles}/> <ScrobbleTable data={profile.scrobbles}/>
</div> </div>

View File

@ -1,9 +1,3 @@
.registerBody {
padding: 20px 5px 5px 5px;
font-size: 16pt;
width: 300px;
}
.registerFields { .registerFields {
width: 100%; width: 100%;
} }

View File

@ -47,7 +47,7 @@ const Register = () => {
<h1> <h1>
Register Register
</h1> </h1>
<div className="registerBody"> <div className="pageBody">
<Formik <Formik
initialValues={{ username: '', email: '', password: '', passwordconfirm: '' }} initialValues={{ username: '', email: '', password: '', passwordconfirm: '' }}
onSubmit={async values => Register(values)} onSubmit={async values => Register(values)}

View File

@ -1,9 +1,3 @@
.resetBody {
padding: 20px 5px 5px 5px;
font-size: 16pt;
width: 300px;
}
.resetFields { .resetFields {
width: 100%; width: 100%;
} }

View File

@ -64,7 +64,7 @@ const Reset = (route) => {
<h1> <h1>
Reset Password Reset Password
</h1> </h1>
<div className="loginBody"> <div className="pageBody">
<Formik <Formik
initialValues={{ email: '' }} initialValues={{ email: '' }}
onSubmit={values => sendReset(values)} onSubmit={values => sendReset(values)}

View File

@ -8,7 +8,7 @@ const Settings = () => {
<h1> <h1>
Settings Settings
</h1> </h1>
<div className="loginBody"> <div className="pageBody">
<p> <p>
All the settings All the settings
</p> </p>

0
web/src/Pages/Track.css Normal file
View File

59
web/src/Pages/Track.js Normal file
View File

@ -0,0 +1,59 @@
import React, { useState, useEffect } from 'react';
import '../App.css';
import './Track.css';
import ScaleLoader from 'react-spinners/ScaleLoader';
import ScrobbleTable from '../Components/ScrobbleTable'
const Track = (route) => {
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState({});
let artist = false;
if (route && route.match && route.match.params && route.match.params.uuid) {
artist = route.match.params.uuid;
} else {
artist = false;
}
useEffect(() => {
if (!artist) {
return false;
}
// getProfile(username)
// .then(data => {
// setProfile(data);
// console.log(data)
// setLoading(false);
// })
}, [artist])
if (loading) {
return (
<div className="pageWrapper">
<ScaleLoader color="#6AD7E5" />
</div>
)
}
if (!artist || !artist) {
return (
<div className="pageWrapper">
Unable to fetch user
</div>
)
}
return (
<div className="pageWrapper">
<h1>
{artist}
</h1>
<div className="pageBody">
Artist
</div>
</div>
);
}
export default Track;

View File

@ -1,8 +1,3 @@
.userBody {
padding: 20px 5px 5px 5px;
font-size: 16pt;
}
.userDropdown { .userDropdown {
color: #282C34; color: #282C34;
font-size: 12pt; font-size: 12pt;

View File

@ -95,7 +95,7 @@ const User = () => {
<h1> <h1>
Welcome {userdata.username} Welcome {userdata.username}
</h1> </h1>
<p className="userBody"> <p className="pageBody">
Timezone<br/> Timezone<br/>
<TimezoneSelect <TimezoneSelect
className="userDropdown" className="userDropdown"