mirror of
https://git.um-react.app/um/um-react.git
synced 2025-11-28 03:23:02 +00:00
feat: basic ui layout
This commit is contained in:
@@ -1 +0,0 @@
|
||||
/* empty file here */
|
||||
19
src/App.tsx
19
src/App.tsx
@@ -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
33
src/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
export const PointerLabel = styled.label`
|
||||
cursor: pointer;
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
56
src/features/file-listing/FileListing.tsx
Normal file
56
src/features/file-listing/FileListing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
src/features/file-listing/fileListingSlice.ts
Normal file
79
src/features/file-listing/fileListingSlice.ts
Normal 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
6
src/hooks.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
12
src/main.tsx
12
src/main.tsx
@@ -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
11
src/store.ts
Normal 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
17
src/theme.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user