initial commit

This commit is contained in:
OfficialDakari 2024-10-07 09:56:10 +05:00
commit 7a146ec027
21 changed files with 8308 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
dist/
node_modules/
./node_modules/
./dist/
./dist
./node_modules
node_modules/*
dist/*

3
build.config.ts Normal file
View File

@ -0,0 +1,3 @@
export default {
base: '/'
};

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>extera music.</title>
</head>
<body>
<div id="root"></div>
<script src="src/index.tsx" type="module"></script>
</body>
</html>

7695
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "frontmusic",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"description": "",
"type": "module",
"dependencies": {
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"@mui/icons-material": "^6.1.2",
"@mui/material": "^6.1.2",
"@rollup/plugin-inject": "^5.0.5",
"@vanilla-extract/css": "^1.16.0",
"@vanilla-extract/vite-plugin": "^4.0.16",
"@vitejs/plugin-react": "^4.3.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-lazy-load-image-component": "^1.6.2",
"react-router-dom": "^6.26.2",
"typescript": "^5.6.2",
"vite-plugin-pwa": "^0.20.5",
"vite-plugin-static-copy": "^1.0.6",
"vite-plugin-top-level-await": "^1.4.4"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/react-lazy-load-image-component": "^1.6.4",
"@types/react-router-dom": "^5.3.3",
"sass-embedded": "^1.79.4",
"vite": "^5.4.8"
}
}

19
src/app/App.tsx Normal file
View File

@ -0,0 +1,19 @@
import React from "react";
import { Router, RouterProvider } from "react-router-dom";
import createRouter from "./Router";
import { createTheme, ThemeProvider } from "@mui/material";
export default function App() {
const router = createRouter();
const theme = createTheme({
palette: {
mode: 'dark'
}
});
return (
<ThemeProvider theme={theme}>
<RouterProvider router={router} />
</ThemeProvider>
);
}

48
src/app/Router.tsx Normal file
View File

@ -0,0 +1,48 @@
import React from "react";
import {
createHashRouter,
createRoutesFromElements,
Outlet,
Route
} from 'react-router-dom';
import AppHeader from "./components/AppHeader";
import { Box, useTheme } from "@mui/material";
import Landing from "./pages/Landing";
import Page from "./components/Page";
import Search from "./pages/Search";
export default function createRouter() {
const theme = useTheme();
const router = createRoutesFromElements(
<Route>
<Route
path='/'
element={
<Page>
<Outlet />
</Page>
}
>
<Route
path='/'
element={
<>
<AppHeader />
<Landing />
</>
}
/>
<Route
path='/search'
element={
<Search />
}
/>
</Route>
</Route>
);
return createHashRouter(router, {
basename: '/'
});
}

View File

@ -0,0 +1,40 @@
import React from "react";
import { AppBar, IconButton, InputBaseProps, Toolbar, Typography } from '@mui/material';
import { SearchContainer, SearchIcon, SearchIconWrapper, SearchInputBase } from "./Search";
import { useNavigate } from "react-router-dom";
type AppHeaderProps = {
showSearch?: boolean;
inputProps?: InputBaseProps;
};
export default function AppHeader({ showSearch, inputProps }: AppHeaderProps) {
const navTo = useNavigate();
return (
<AppBar position='static'>
<Toolbar>
{showSearch ? (
<SearchContainer>
<SearchIconWrapper><SearchIcon /></SearchIconWrapper>
<SearchInputBase
placeholder='Search songs...'
{...inputProps}
/>
</SearchContainer>
) : (
<>
<Typography variant="h6" component='div' flexGrow={1}>
Extera Music
</Typography>
<IconButton
onClick={() => navTo('/search')}
size='large'
edge='end'
>
<SearchIcon />
</IconButton>
</>
)}
</Toolbar>
</AppBar>
);
}

View File

@ -0,0 +1,42 @@
import { Box, Skeleton, useTheme } from "@mui/material";
import React from "react";
export default function LoadingCard() {
const theme = useTheme();
return (
<Box
display='flex'
flexDirection='column'
>
<Skeleton
variant='rectangular'
width={120}
height={120}
/>
<Skeleton variant='text' width={(Math.random() * (110 - 30)) + 30} />
</Box>
);
}
export function LoadingSearchSongCard() {
return (
<Box
display='flex'
flexDirection='row'
gap='0.5rem'
>
<Skeleton
width={64}
height={64}
/>
<Box
display='flex'
flexDirection='column'
>
<Skeleton width={(Math.random() * (110 - 60)) + 60} variant='text' />
<Skeleton width={(Math.random() * (60 - 30)) + 30} variant='text' />
</Box>
</Box>
);
}

View File

@ -0,0 +1,25 @@
import { Box, useTheme } from "@mui/material";
import React from "react";
export default function Page(props: React.PropsWithChildren) {
const theme = useTheme();
return (
<Box
sx={{
minHeight: '100vh',
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary
}}
{...props}
/>
);
}
export function PageRoot(props: React.PropsWithChildren) {
return (
<Box
{...props}
/>
);
}

View File

@ -0,0 +1,13 @@
import { Box } from "@mui/material";
import React from "react";
export default function Scroll({...props}: any) {
return (
<Box
sx={{
overflow: 'scroll'
}}
{...props}
/>
);
}

View File

@ -0,0 +1,49 @@
import React from 'react';
import { styled, alpha } from '@mui/material/styles';
import InputBase from '@mui/material/InputBase';
import SearchIcon from '@mui/icons-material/Search';
const SearchContainer = styled('div')(({ theme }) => ({
position: 'relative',
borderRadius: theme.shape.borderRadius,
backgroundColor: alpha(theme.palette.common.white, 0.15),
'&:hover': {
backgroundColor: alpha(theme.palette.common.white, 0.25),
},
width: '100%',
[theme.breakpoints.up('sm')]: {
width: 'auto',
},
}));
const SearchIconWrapper = styled('div')(({ theme }) => ({
padding: theme.spacing(0, 2),
height: '100%',
position: 'absolute',
//pointerEvents: '',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}));
const StyledInputBase = styled(InputBase)(({ theme }) => ({
color: 'inherit',
width: '100%',
'& .MuiInputBase-input': {
padding: theme.spacing(1, 1, 1, 0),
// vertical padding + font size from searchIcon
paddingLeft: `calc(1em + ${theme.spacing(4)})`,
transition: theme.transitions.create('width'),
width: '100%',
[theme.breakpoints.up('md')]: {
width: '20ch',
},
},
}));
export {
SearchContainer,
StyledInputBase as SearchInputBase,
SearchIconWrapper,
SearchIcon
};

View File

@ -0,0 +1,67 @@
import { Box, Skeleton, Typography, useTheme } from "@mui/material";
import React, { MouseEventHandler } from "react";
import { LazyLoadImage } from 'react-lazy-load-image-component';
type SongCardProps = {
onClick?: MouseEventHandler;
thumbnailUrl?: string;
artist: string;
name: string;
album: string;
};
export default function SongCard({ onClick, thumbnailUrl, artist, name, album }: SongCardProps) {
const theme = useTheme();
return (
<Box
display='flex'
flexDirection='column'
>
<img
width={120}
height={120}
src={thumbnailUrl}
/>
<Typography>
{name}
</Typography>
</Box>
);
}
export function SearchSongCard({ onClick, thumbnailUrl, artist, name, album }: SongCardProps) {
const theme = useTheme();
return (
<Box
display='flex'
flexDirection='row'
gap='0.5rem'
sx={{
':hover': {
backgroundColor: theme.palette.action.hover,
borderRadius: '1rem'
}
}}
onClick={onClick}
>
<LazyLoadImage
width={60}
height={60}
src={thumbnailUrl}
/>
<Box
display='flex'
flexDirection='column'
>
<Typography variant='subtitle1'>
{name}
</Typography>
<Typography variant='subtitle2'>
{artist}
</Typography>
</Box>
</Box>
);
}

31
src/app/pages/Landing.tsx Normal file
View File

@ -0,0 +1,31 @@
import { Box, Typography, useTheme } from "@mui/material";
import React from "react";
import LoadingCard from "../components/LoadingCard";
import Scroll from "../components/Scroll";
export default function Landing() {
const theme = useTheme();
return (
<Box
sx={{
p: theme.spacing(1)
}}
>
<Typography variant='button'>Trending</Typography>
<Scroll display='flex' gap={theme.spacing(2)}>
<LoadingCard />
<LoadingCard />
<LoadingCard />
<LoadingCard />
<LoadingCard />
<LoadingCard />
<LoadingCard />
<LoadingCard />
<LoadingCard />
<LoadingCard />
<LoadingCard />
</Scroll>
</Box>
);
}

57
src/app/pages/Search.tsx Normal file
View File

@ -0,0 +1,57 @@
import React, { KeyboardEvent, ReactNode, useRef, useState } from "react";
import { PageRoot } from "../components/Page";
import AppHeader from "../components/AppHeader";
import repeatArray from "../utils/repeatArray";
import { LoadingSearchSongCard } from "../components/LoadingCard";
import { searchSongs } from "../utils/MusicServer";
import { Box } from "@mui/material";
import { SearchSongCard } from "../components/SongCard";
export default function Search() {
const inputRef = useRef<HTMLInputElement>(null);
const [results, setResults] = useState<ReactNode[]>([]);
const onKeyUp = async (evt: KeyboardEvent) => {
if (evt.key !== 'Enter') return;
const q = inputRef.current?.value;
if (typeof q !== 'string') return;
setResults(
repeatArray(<LoadingSearchSongCard />, 5)
);
const songs = await searchSongs(q);
const arr: ReactNode[] = [];
for (const song of songs) {
arr.push(
<SearchSongCard
album={song.album.name}
artist={song.artist.name}
name={song.name}
thumbnailUrl={song.thumbnails[0].url}
onClick={() => alert(song.name)}
/>
);
}
setResults(arr);
};
return (
<>
<AppHeader
showSearch
inputProps={{
onKeyUp,
inputRef
}}
/>
<PageRoot>
<Box
p='1rem'
display='flex'
flexDirection='column'
gap='0.5rem'
>
{results}
</Box>
</PageRoot>
</>
);
}

View File

@ -0,0 +1,48 @@
type Artist = {
name: string;
artistId: string;
};
type Thumbnail = {
url: string;
width: number;
height: number;
};
type Album = {
name: string;
albumId: string;
};
type Song = {
type: 'SONG';
videoId: string;
name: string;
artist: Artist;
duration: number;
thumbnails: Thumbnail[];
album: Album;
};
async function doPost(p: string, json: any) {
const jsonString = JSON.stringify(json);
const response = await fetch(`http://localhost:33223${p}`, {
headers: {
'Content-Type': 'application/json'
},
body: jsonString,
method: 'POST'
});
if (!response.ok) throw await response.json();
return await response.json();
}
async function doGet(p: string) {
const response = await fetch(`http://localhost:33223${p}`);
if (!response.ok) throw await response.json();
return await response.json();
}
export async function searchSongs(q: string): Promise<Song[]> {
return await doGet(`/music/search?q=${encodeURIComponent(q)}`);
}

View File

@ -0,0 +1,7 @@
export default function repeatArray<T>(el: T, t: number): T[] {
const arr: T[] = [];
for (let i = 0; i < t; i ++) {
arr.push(el);
}
return arr;
}

3
src/index.scss Normal file
View File

@ -0,0 +1,3 @@
body {
margin: 0;
}

18
src/index.tsx Normal file
View File

@ -0,0 +1,18 @@
import React from "react";
import {createRoot} from 'react-dom/client';
import App from "./app/App";
import './index.scss';
const mountApp = () => {
const rootContainer = document.getElementById('root');
if (rootContainer === null) {
document.write('Root not found.');
return;
}
const root = createRoot(rootContainer);
root.render(<App />);
};
mountApp();

26
tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"sourceMap": true,
"jsx": "react",
"target": "ES2016",
"module": "ES2020",
"lib": [
"ES2021",
"DOM"
],
"allowJs": true,
"strict": true,
"esModuleInterop": true,
"moduleResolution": "Node",
"resolveJsonModule": true,
"outDir": "dist",
"skipLibCheck": true
},
"exclude": [
"node_modules",
"dist"
],
"include": [
"src"
]
}

57
vite.config.js Normal file
View File

@ -0,0 +1,57 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill';
import inject from '@rollup/plugin-inject';
import topLevelAwait from 'vite-plugin-top-level-await';
import buildConfig from './build.config';
const copyFiles = {
targets: [
],
};
export default defineConfig({
appType: 'spa',
publicDir: false,
base: buildConfig.base,
server: {
port: 8080,
host: '0.0.0.0'
},
plugins: [
topLevelAwait({
// The export name of top-level await promise for each chunk module
promiseExportName: '__tla',
// The function to generate import names of top-level await promise in each chunk module
promiseImportName: (i) => `__tla_${i}`,
}),
viteStaticCopy(copyFiles),
vanillaExtractPlugin(),
react(),
],
optimizeDeps: {
esbuildOptions: {
define: {
global: 'globalThis',
},
plugins: [
// Enable esbuild polyfill plugins
NodeGlobalsPolyfillPlugin({
process: false,
buffer: true
}),
],
},
},
build: {
outDir: 'dist',
sourcemap: true,
copyPublicDir: false,
rollupOptions: {
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })],
},
},
});