feat: basic ui layout

This commit is contained in:
Jixun Wu 2023-05-07 23:29:37 +01:00
parent 0c4713bd3a
commit 37f6667e50
16 changed files with 375 additions and 104 deletions

View File

@ -4,3 +4,9 @@
pnpm i
pnpm start
```
## TODO
- 文件拖放 (利用 `react-dropzone`?)
- 各类算法
- 简易元数据编辑器

View File

@ -2,12 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>音乐解锁 - Unlock Music</title>
</head>
<body>
<div id="root"></div>
<main id="root"></main>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -14,9 +14,11 @@
"@chakra-ui/react": "^2.6.1",
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@reduxjs/toolkit": "^1.9.5",
"framer-motion": "^10.12.8",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-redux": "^8.0.5"
},
"devDependencies": {
"@types/react": "^18.0.28",

View File

@ -13,6 +13,9 @@ dependencies:
'@emotion/styled':
specifier: ^11.11.0
version: 11.11.0(@emotion/react@11.11.0)(@types/react@18.0.28)(react@18.2.0)
'@reduxjs/toolkit':
specifier: ^1.9.5
version: 1.9.5(react-redux@8.0.5)(react@18.2.0)
framer-motion:
specifier: ^10.12.8
version: 10.12.8(react-dom@18.2.0)(react@18.2.0)
@ -22,6 +25,9 @@ dependencies:
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
react-redux:
specifier: ^8.0.5
version: 8.0.5(@types/react-dom@18.0.11)(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1)
devDependencies:
'@types/react':
@ -1818,6 +1824,32 @@ packages:
resolution: {integrity: sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==}
dev: false
/@reduxjs/toolkit@1.9.5(react-redux@8.0.5)(react@18.2.0):
resolution: {integrity: sha512-Rt97jHmfTeaxL4swLRNPD/zV4OxTes4la07Xc4hetpUW/vc75t5m1ANyxG6ymnEQ2FsLQsoMlYB2vV1sO3m8tQ==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18
react-redux: ^7.2.1 || ^8.0.2
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
dependencies:
immer: 9.0.21
react: 18.2.0
react-redux: 8.0.5(@types/react-dom@18.0.11)(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1)
redux: 4.2.1
redux-thunk: 2.4.2(redux@4.2.1)
reselect: 4.1.8
dev: false
/@types/hoist-non-react-statics@3.3.1:
resolution: {integrity: sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==}
dependencies:
'@types/react': 18.0.28
hoist-non-react-statics: 3.3.2
dev: false
/@types/json-schema@7.0.11:
resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
dev: true
@ -1843,7 +1875,6 @@ packages:
resolution: {integrity: sha512-O38bPbI2CWtgw/OoQoY+BRelw7uysmXbWvw3nLWO21H1HSh+GOlqPuXshJfjmpNlKiiSDG9cc1JZAaMmVdcTlw==}
dependencies:
'@types/react': 18.0.28
dev: true
/@types/react@18.0.28:
resolution: {integrity: sha512-RD0ivG1kEztNBdoAK7lekI9M+azSnitIn85h4iOiaLjaTrMjzslhaqCGaI4IyCJ1RljWiLCEu4jyrLLgqxBTew==}
@ -1859,6 +1890,10 @@ packages:
resolution: {integrity: sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==}
dev: true
/@types/use-sync-external-store@0.0.3:
resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==}
dev: false
/@typescript-eslint/eslint-plugin@5.57.1(@typescript-eslint/parser@5.57.1)(eslint@8.38.0)(typescript@5.0.2):
resolution: {integrity: sha512-1MeobQkQ9tztuleT3v72XmY0XuKXVXusAhryoLuU5YZ+mXoYKZP9SQ7Flulh1NX4DTjpGTc2b/eMu4u7M7dhnQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -2625,6 +2660,10 @@ packages:
engines: {node: '>= 4'}
dev: true
/immer@9.0.21:
resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
dev: false
/import-fresh@3.3.0:
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
engines: {node: '>=6'}
@ -2978,6 +3017,44 @@ packages:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: false
/react-is@18.2.0:
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
dev: false
/react-redux@8.0.5(@types/react-dom@18.0.11)(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1):
resolution: {integrity: sha512-Q2f6fCKxPFpkXt1qNRZdEDLlScsDWyrgSj0mliK59qU6W5gvBiKkdMEG2lJzhd1rCctf0hb6EtePPLZ2e0m1uw==}
peerDependencies:
'@types/react': ^16.8 || ^17.0 || ^18.0
'@types/react-dom': ^16.8 || ^17.0 || ^18.0
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
react-native: '>=0.59'
redux: ^4
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
react-dom:
optional: true
react-native:
optional: true
redux:
optional: true
dependencies:
'@babel/runtime': 7.21.5
'@types/hoist-non-react-statics': 3.3.1
'@types/react': 18.0.28
'@types/react-dom': 18.0.11
'@types/use-sync-external-store': 0.0.3
hoist-non-react-statics: 3.3.2
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-is: 18.2.0
redux: 4.2.1
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false
/react-refresh@0.14.0:
resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==}
engines: {node: '>=0.10.0'}
@ -3042,10 +3119,28 @@ packages:
loose-envify: 1.4.0
dev: false
/redux-thunk@2.4.2(redux@4.2.1):
resolution: {integrity: sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==}
peerDependencies:
redux: ^4
dependencies:
redux: 4.2.1
dev: false
/redux@4.2.1:
resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==}
dependencies:
'@babel/runtime': 7.21.5
dev: false
/regenerator-runtime@0.13.11:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
dev: false
/reselect@4.1.8:
resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==}
dev: false
/resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -3276,6 +3371,14 @@ packages:
tslib: 2.5.0
dev: false
/use-sync-external-store@1.2.0(react@18.2.0):
resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
react: 18.2.0
dev: false
/vite@4.3.2:
resolution: {integrity: sha512-9R53Mf+TBoXCYejcL+qFbZde+eZveQLDYd9XgULILLC1a5ZwPaqgmdVpL8/uvw2BM/1TzetWjglwm+3RO+xTyw==}
engines: {node: ^14.18.0 || >=16.0.0}

View File

@ -1 +0,0 @@
/* empty file here */

View File

@ -1,11 +1,22 @@
import './App.css';
import { Box, Center, Container } from '@chakra-ui/react';
import { SelectFile } from './SelectFile';
import { FileListing } from './features/file-listing/FileListing';
import { Footer } from './Footer';
function App() {
return (
<main>
<SelectFile />
</main>
<Box height="full" width="full" pt="4">
<Container maxW="container.large">
<Center>
<SelectFile />
</Center>
<Box mt="8">
<FileListing />
</Box>
<Footer />
</Container>
</Box>
);
}

33
src/Footer.tsx Normal file
View File

@ -0,0 +1,33 @@
import { Center, Link, Text } from '@chakra-ui/react';
export function Footer() {
return (
<Center height="footer.container">
<Center
height="footer.content"
fontSize="sm"
textAlign="center"
position="fixed"
bottom="0"
w="full"
bg="gray.100"
color="gray.800"
left="0"
flexDir="column"
>
<Text> (x.x.x) - </Text>
<Text>
Copyright © 2019 - 2023{' '}
<Link href="https://git.unlock-music.dev/um" isExternal>
UnlockMusic
</Link>{' '}
|
<Link href="https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE" isExternal>
MIT许可协议
</Link>
</Text>
</Center>
</Center>
);
}

View File

@ -1,5 +0,0 @@
import styled from '@emotion/styled';
export const PointerLabel = styled.label`
cursor: pointer;
`;

View File

@ -1,25 +1,44 @@
import { Box, Stack, Text } from '@chakra-ui/react';
import { UnlockIcon } from '@chakra-ui/icons';
import { useId } from 'react';
import { PointerLabel } from './PointerLabel';
import { Box, Text } from '@chakra-ui/react';
import { UnlockIcon } from '@chakra-ui/icons';
export function SelectFile() {
const id = useId();
return (
<Box borderWidth="1px" borderRadius="lg" p="6">
<Stack alignItems="center">
<UnlockIcon />
<Box>
<PointerLabel htmlFor={id}>
<Text as="span" color="teal.400">
</Text>
</PointerLabel>
<input id={id} type="file" hidden multiple />
</Box>
</Stack>
<Box
as="label"
htmlFor={id}
w="100%"
maxW={480}
borderWidth="1px"
borderRadius="lg"
transitionDuration="0.5s"
p="6"
cursor="pointer"
display="flex"
flexDir="column"
alignItems="center"
_hover={{
borderColor: 'gray.400',
bg: 'gray.50',
}}
>
<Box pb={3}>
<UnlockIcon boxSize={8} />
</Box>
<Box textAlign="center">
{/* 将文件拖到此处,或 */}
<Text as="span" color="teal.400">
</Text>
<input id={id} type="file" hidden multiple />
<Text fontSize="sm" opacity="50%">
</Text>
</Box>
</Box>
);
}

View File

@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { Avatar, Box, Table, TableContainer, Tbody, Td, Text, Th, Thead, Tr, Wrap, WrapItem } from '@chakra-ui/react';
import { addNewFile, selectFiles } from './fileListingSlice';
import { useAppDispatch, useAppSelector } from '../../hooks';
export function FileListing() {
const dispatch = useAppDispatch();
const files = useAppSelector(selectFiles);
useEffect(() => {
// FIXME: Remove test data
if (files.length === 0) {
dispatch(addNewFile({ id: String(Date.now()), fileName: '测试文件名.mgg', blobURI: '' }));
}
}, []);
return (
<TableContainer>
<Table variant="striped">
<Thead>
<Tr>
<Th w="1%"></Th>
<Th></Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{files.map((file) => (
<Tr key={file.id}>
<Td>
{file.metadata.cover && <Avatar size="sm" name="专辑封面" src={file.metadata.cover} />}
{!file.metadata.cover && <Text></Text>}
</Td>
<Td>
<Box as="h4" fontWeight="semibold" mt="1">
{file.metadata.name || file.fileName}
</Box>
<Text>: {file.metadata.album}</Text>
<Text>: {file.metadata.artist}</Text>
<Text>: {file.metadata.albumArtist}</Text>
</Td>
<Td>
<Wrap>
<WrapItem></WrapItem>
<WrapItem></WrapItem>
<WrapItem></WrapItem>
</Wrap>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
);
}

View File

@ -0,0 +1,79 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '../../store';
export enum ProcessState {
UNTOUCHED = 'UNTOUCHED',
COMPLETE = 'COMPLETE',
ERROR = 'ERROR',
}
export enum ListingMode {
LIST = 'LIST',
CARD = 'CARD',
}
export interface AudioMetadata {
name: string;
artist: string;
album: string;
albumArtist: string;
cover: string; // blob uri
}
export interface DecryptedAudioFile {
id: string;
fileName: string;
raw: string; // blob uri
decrypted: string; // blob uri
state: ProcessState;
errorMessage: null | string;
metadata: AudioMetadata;
}
export interface FileListingState {
files: DecryptedAudioFile[];
displayMode: ListingMode;
}
const initialState: FileListingState = {
files: [],
displayMode: ListingMode.LIST,
};
export const fileListingSlice = createSlice({
name: 'fileListing',
initialState,
reducers: {
addNewFile: (state, { payload }: PayloadAction<{ id: string; fileName: string; blobURI: string }>) => {
state.files.push({
id: payload.id,
fileName: payload.fileName,
raw: payload.blobURI,
decrypted: '',
state: ProcessState.UNTOUCHED,
errorMessage: null,
metadata: {
name: '',
artist: '',
album: '',
albumArtist: '',
cover: '',
},
});
},
setDecryptedContent: (state, { payload }: PayloadAction<{ id: string; decryptedBlobURI: string }>) => {
const file = state.files.find((file) => file.id === payload.id);
if (file) {
file.decrypted = payload.decryptedBlobURI;
}
},
},
});
export const { addNewFile, setDecryptedContent } = fileListingSlice.actions;
export const selectFileCount = (state: RootState) => state.fileListing.files.length;
export const selectFiles = (state: RootState) => state.fileListing.files;
export const selectFileListingMode = (state: RootState) => state.fileListing.displayMode;
export default fileListingSlice.reducer;

6
src/hooks.ts Normal file
View File

@ -0,0 +1,6 @@
import { useDispatch, useSelector } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

@ -1,69 +0,0 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@ -1,14 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import App from './App';
import { ChakraProvider } from '@chakra-ui/react';
import { Provider } from 'react-redux';
import { store } from './store';
import { theme } from './theme';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<ChakraProvider>
<App />
<ChakraProvider theme={theme}>
<Provider store={store}>
<App />
</Provider>
</ChakraProvider>
</React.StrictMode>
);

11
src/store.ts Normal file
View File

@ -0,0 +1,11 @@
import { configureStore } from '@reduxjs/toolkit';
import fileListing from './features/file-listing/fileListingSlice';
export const store = configureStore({
reducer: {
fileListing: fileListing,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

17
src/theme.ts Normal file
View File

@ -0,0 +1,17 @@
import { extendTheme } from '@chakra-ui/react';
export const theme = extendTheme({
styles: {
global: {
body: {
minHeight: '100vh',
},
},
},
sizes: {
footer: {
container: '7rem',
content: '5rem',
},
},
});