refactor: 自定义主题表单布局重构

This commit is contained in:
streakingman 2022-10-11 04:59:08 +08:00
parent 9f613ade82
commit 1890cc5a6f
5 changed files with 294 additions and 632 deletions

View File

@ -15,109 +15,146 @@
overflow-y: auto;
color: rgb(255 255 255 / 87%);
background-color: #242424;
z-index: 20 !important;
position: fixed;
left: 50%;
top: 0;
width: calc(100% - 32px);
max-width: 500px;
bottom: 0;
animation: ease-in-out show 0.3s both;
transition: 0.3s;
padding: 16px;
display: flex;
flex-flow: column nowrap !important;
gap: 8px;
h2 {
text-align: center;
margin-top: -36px;
}
@media screen and (min-width: 1024px) {
margin: 36px 0;
border-radius: 16px;
box-shadow: 0 19px 38px rgb(0 0 0 / 30%), 0 15px 12px rgb(0 0 0 / 22%);
}
@media (prefers-color-scheme: light) {
color: #213547;
background-color: #fff;
}
&Wrapper {
z-index: 10;
position: fixed;
left: 50%;
top: 0;
width: calc(100% - 32px);
max-width: 500px;
bottom: 0;
animation: ease-in-out show 0.3s both;
transition: 0.3s;
padding: 16px;
display: flex;
flex-flow: column nowrap !important;
@media screen and (min-width: 1024px) {
margin: 36px 0;
border-radius: 16px;
box-shadow: 0 19px 38px rgb(0 0 0 / 30%),
0 15px 12px rgb(0 0 0 / 22%);
}
}
.error {
.errorTip {
color: crimson;
}
h4 {
margin: 8px 0;
}
}
.add {
&Btn {
border-radius: 8px;
width: 50px;
height: 50px;
border: 1px solid gray;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
&Empty::before {
content: '+';
color: #999;
font-size: 2em;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&Dialog {
position: absolute;
left: 50%;
top: 50%;
width: 90%;
padding: 12px;
transform: translateX(-50%) translateY(-60vh);
opacity: 0;
transition: 0.3s;
border-radius: 8px;
border: 2px solid #535bf2;
color: rgb(255 255 255 / 87%);
background-color: #242424;
// 写冗余了待优化
@media (prefers-color-scheme: light) {
color: #213547;
background-color: #fff;
}
&Show {
transform: translateX(-50%) translateY(-50%);
opacity: 1;
}
}
}
.delete {
&Btn {
flex-grow: 1;
.closeBtn {
position: sticky;
background-color: rgb(0 0 0 / 30%);
border-radius: 4px;
left: calc(100% - 36px);
top: 0;
width: 36px;
height: 36px;
min-height: 36px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f9f9f9;
font-size: 1.5em;
color: #999;
backdrop-filter: blur(5px);
cursor: pointer;
span {
transform: rotate(45deg);
svg {
transition: 0.3s;
}
&:hover {
svg {
transform: rotate(180deg);
}
}
}
}
.inputContainer {
word-break: keep-all;
input,
select {
flex-grow: 1;
}
input[type='file'] {
padding: 4px 12px;
}
input[type='checkbox'] {
flex-grow: 0;
width: 24px;
height: 24px;
}
input[type='color'] {
padding: 0 2px;
flex-grow: 0;
width: 30px;
height: 30px;
}
.label {
min-width: 74px;
opacity: 0.7;
font-weight: 600;
}
.tip {
font-size: 14px;
opacity: 0.6;
word-break: break-all;
}
&.required .label {
&::after {
content: '*';
color: red;
}
}
}
.divider {
width: 100%;
height: 0;
border-bottom: 1px solid rgb(0 0 0 / 8%);
}
.soundItem {
height: 30px;
position: relative;
.inner {
height: 30px;
min-width: 48px;
margin-left: 42px;
background-color: #888;
line-height: 30px;
padding: 0 12px;
border-bottom-right-radius: 15px;
border-top-right-radius: 15px;
font-size: 14px;
color: white;
position: relative;
z-index: 2;
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
}
audio {
height: 30px;
width: 100px;
position: absolute;
left: 0;
z-index: 1;
}
}

View File

@ -1,240 +1,87 @@
import React, { FC, useEffect, useRef, useState } from 'react';
import React, { FC, ReactNode, useEffect, useRef, useState } from 'react';
import style from './ConfigDialog.module.scss';
import classNames from 'classnames';
import { Icon, Sound, Theme } from '../themes/interface';
import { QRCodeCanvas } from 'qrcode.react';
import Bmob from 'hydrogen-js-sdk';
import { captureElement, LAST_UPLOAD_TIME_STORAGE_KEY } from '../utils';
import {
captureElement,
CUSTOM_THEME_STORAGE_KEY,
LAST_UPLOAD_TIME_STORAGE_KEY,
randomString,
wrapThemeDefaultSounds,
} from '../utils';
import { copy } from 'clipboard';
import { CloseIcon } from './CloseIcon';
import WxQrCode from './WxQrCode';
const STORAGEKEY = 'customTheme';
const InputContainer: FC<{
label: string;
required?: boolean;
children: ReactNode;
}> = ({ label, children, required }) => {
return (
<>
<div className={style.divider} />
<div
className={classNames(
'flex-container flex-center flex-no-wrap',
style.inputContainer,
required && style.required
)}
>
<span className={style.label}>{label}</span>
<div className={'flex-container flex-column flex-grow'}>
{children}
</div>
</div>
</>
);
};
const ConfigDialog: FC<{
closeMethod: () => void;
previewMethod: (theme: Theme<string>) => void;
}> = ({ closeMethod, previewMethod }) => {
const [sounds, setSounds] = useState<Sound[]>([]);
const [icons, setIcons] = useState<Icon[]>([]);
const inputRefMap = useRef<
Record<
'name' | 'link' | 'clickSound' | 'tripleSound' | string,
HTMLInputElement | HTMLSelectElement
>
>({});
// 错误提示
const [configError, setConfigError] = useState<string>('');
const [customThemeInfo, setCustomThemeInfo] = useState<{
title: string;
desc?: string;
bgm?: string;
background?: string;
backgroundBlur?: boolean;
}>({ title: '', desc: '', bgm: '', background: '', backgroundBlur: false });
const [addDialog, setAddDialog] = useState<{
show: boolean;
type: 'sound' | 'icon';
iconForm?: Icon;
soundForm?: Sound;
error: string;
idx?: number;
}>({
show: false,
type: 'sound',
error: '',
});
// 生成链接
const [genLink, setGenLink] = useState<string>('');
const [pureCount, setPureCount] = useState<number>(0);
const [customTheme, setCustomTheme] = useState<Theme<any>>({
title: '',
sounds: [],
icons: new Array(10).fill(0).map(() => ({
name: randomString(4),
content: '',
clickSound: '',
tripleSound: '',
})),
});
// 编辑中音效
const [editSound, setEditSound] = useState<Sound>({ name: '', src: '' });
// 初始化
useEffect(() => {
let storageTheme: Theme<any> | undefined = undefined;
try {
const configString = localStorage.getItem(STORAGEKEY);
const configString = localStorage.getItem(CUSTOM_THEME_STORAGE_KEY);
if (configString) {
const parseRes = JSON.parse(configString);
if (typeof parseRes === 'object') storageTheme = parseRes;
if (typeof parseRes === 'object') {
setCustomTheme(parseRes);
}
}
} catch (e) {
//
console.log(e);
}
if (!storageTheme) return;
const {
title,
desc = '',
bgm = '',
sounds,
icons,
background = '',
backgroundBlur = false,
} = storageTheme;
setSounds(
sounds.filter((s) => !['triple', 'button-click'].includes(s.name))
);
setIcons(
icons.map((icon) => {
if (icon.clickSound === 'button-click') icon.clickSound = '';
if (icon.tripleSound === 'triple') icon.tripleSound = '';
return icon;
})
);
setCustomThemeInfo({
title,
// @ts-ignore
desc,
bgm,
background,
backgroundBlur,
});
}, []);
// 音效保存
const saveSound = (sound: Sound, idx?: number) => {
if (!sound.src.startsWith('https')) return '请输入https链接';
const newSounds = sounds.slice();
const newIcons = icons.slice();
if (idx != null) {
// 编辑
for (let i = 0; i < sounds.length; i++) {
if (sounds[i].name === sound.name && i !== idx) {
return '名称已存在';
}
}
// 检查编辑的音效是否有引用并修改
const oldSoundName = sounds[idx].name;
for (const icon of newIcons) {
if (icon.clickSound === oldSoundName)
icon.clickSound = sound.name;
if (icon.tripleSound === oldSoundName)
icon.tripleSound = sound.name;
}
newSounds[idx] = sound;
} else {
// 新增
if (sounds.find((s) => s.name === sound.name)) return '名称已存在';
newSounds.push(sound);
}
setIcons(newIcons);
setSounds(newSounds);
};
const onSoundClick = (idx?: number) => {
if (addDialog.show) return;
setAddDialog({
idx,
show: true,
type: 'sound',
soundForm: {
name: '',
src: '',
},
error: '',
});
};
// 图片保存
const saveIcon = (icon: Icon, idx?: number) => {
if (
typeof icon.content !== 'string' ||
!icon.content?.startsWith('https')
)
return '请输入https链接';
const newIcons = icons.slice();
if (idx != null) {
// 编辑
for (let i = 0; i < icons.length; i++) {
if (icons[i].name === icon.name && i !== idx) {
return '名称已存在';
}
}
newIcons[idx] = icon;
} else {
// 新增
if (icons.find((i) => i.name === icon.name)) return '名称已存在';
newIcons.push(icon);
}
setIcons(newIcons);
};
const onIconClick = (idx?: number) => {
if (addDialog.show) return;
setAddDialog({
idx,
show: true,
type: 'icon',
iconForm:
idx != null
? { ...icons[idx] }
: {
name: '',
content: '',
tripleSound: '',
clickSound: '',
},
error: '',
});
};
// 回显
useEffect(() => {
const { show, type, idx } = addDialog;
if (show) return;
if (!inputRefMap.current) return;
if (type === 'icon') {
inputRefMap.current.name.value = idx != null ? icons[idx].name : '';
inputRefMap.current.link.value =
idx != null ? (icons[idx].content as string) : '';
inputRefMap.current.clickSound.value =
idx != null ? icons[idx]?.clickSound || '' : '';
inputRefMap.current.tripleSound.value =
idx != null ? icons[idx]?.tripleSound || '' : '';
} else {
inputRefMap.current.name.value =
idx != null ? sounds[idx].name : '';
inputRefMap.current.link.value = idx != null ? sounds[idx].src : '';
}
}, [addDialog]);
// 添加单项的点击
const onAddDialogSaveClick = () => {
const error = (addDialog.type === 'sound' ? saveSound : saveIcon)(
addDialog[`${addDialog.type}Form`] as any,
addDialog.idx
);
if (error) {
setAddDialog({ ...addDialog, error });
} else {
closeAddDialog();
}
};
// 关闭添加弹窗
const closeAddDialog = () => {
setAddDialog({ ...addDialog, show: false });
};
// 生成主题
const generateTheme: () => Promise<Theme<any>> = async () => {
const { title, desc, bgm, background, backgroundBlur } =
customThemeInfo;
if (bgm && !bgm.startsWith('https'))
return Promise.reject('背景音乐请输入https链接');
if (background && !background.startsWith('https'))
return Promise.reject('背景图片请输入https链接');
if (!title) return Promise.reject('请填写标题');
if (icons.length !== 10) return Promise.reject('图片素材需要提供10张');
const customTheme: Theme<any> = {
// 恭喜你发现纯净模式彩蛋🎉,点击文字十次可以开启纯净模式
pure: pureCount !== 0 && pureCount % 10 === 0,
title,
desc,
bgm,
background,
backgroundBlur,
icons,
sounds,
};
console.log(customTheme);
return Promise.resolve(JSON.parse(JSON.stringify(customTheme)));
// TODO 校验
const cloneTheme = JSON.parse(JSON.stringify(customTheme));
wrapThemeDefaultSounds(cloneTheme);
return Promise.resolve(cloneTheme);
};
// 预览
@ -243,7 +90,10 @@ const ConfigDialog: FC<{
generateTheme()
.then((theme) => {
previewMethod(theme);
localStorage.setItem(STORAGEKEY, JSON.stringify(theme));
localStorage.setItem(
CUSTOM_THEME_STORAGE_KEY,
JSON.stringify(theme)
);
closeMethod();
})
.catch((e) => {
@ -276,7 +126,7 @@ const ConfigDialog: FC<{
}
const stringify = JSON.stringify(theme);
localStorage.setItem(STORAGEKEY, stringify);
localStorage.setItem(CUSTOM_THEME_STORAGE_KEY, stringify);
const query = Bmob.Query('config');
query.set('content', stringify);
query
@ -305,338 +155,101 @@ const ConfigDialog: FC<{
});
};
// 删除按钮
const DeleteBtn: FC<{ idx: number; type: 'sound' | 'icon' }> = ({
idx,
type,
}) => {
const deleteItem = () => {
if (type === 'sound') {
const newSounds = sounds.slice();
newSounds.splice(idx, 1);
setSounds(newSounds);
} else {
const newIcons = icons.slice();
newIcons.splice(idx, 1);
setIcons(newIcons);
}
};
return (
<div className={style.deleteBtn} onClick={deleteItem}>
<span>+</span>
</div>
);
};
// TODO HTML有点臭长了待优化
// @ts-ignore
return (
<div
className={classNames(
style.dialog,
style.dialogWrapper,
'flex-container flex-container'
)}
>
<p onClick={() => setPureCount(pureCount + 1)}>
https链接/
mp3外链
{pureCount != 0 &&
pureCount % 10 === 0 &&
'🎉🎉🎉恭喜发现彩蛋!主题分享后将开启纯净模式~'}
</p>
<div className="flex-container flex-no-wrap">
<img
style={{ width: 120, objectFit: 'contain' }}
src="/wxqrcode.png"
alt=""
/>
<p style={{ margin: 0 }}>
<strong>
</strong>
</p>
<div className={classNames(style.dialog)}>
<div className={style.closeBtn} onClick={closeMethod}>
<CloseIcon fill={'#fff'} />
</div>
<h2></h2>
{/*基本配置*/}
<h4 className="flex-container flex-center">
<input
value={customThemeInfo.title}
placeholder="必填"
className="flex-grow"
onChange={(e) =>
setCustomThemeInfo({
...customThemeInfo,
title: e.target.value,
})
}
/>
</h4>
<h4 className="flex-container flex-center">
<input
value={customThemeInfo.desc}
placeholder="可选"
className="flex-grow"
onChange={(e) =>
setCustomThemeInfo({
...customThemeInfo,
desc: e.target.value,
})
}
/>
</h4>
<h4 className="flex-container flex-center">
<input
value={customThemeInfo.bgm}
placeholder="可选 https://example.com/src.audio"
className="flex-grow"
onChange={(e) =>
setCustomThemeInfo({
...customThemeInfo,
bgm: e.target.value,
})
}
/>
</h4>
<h4 className="flex-container flex-center">
<input
value={customThemeInfo.background}
placeholder="可选 https://example.com/src.image"
className="flex-grow"
onChange={(e) =>
setCustomThemeInfo({
...customThemeInfo,
background: e.target.value,
})
}
/>
{customThemeInfo?.background?.startsWith('https') && (
<>
<input
checked={customThemeInfo.backgroundBlur}
onChange={(e) =>
setCustomThemeInfo({
...customThemeInfo,
backgroundBlur: e.target.checked,
})
}
type="checkbox"
/>
</>
)}
</h4>
<h4></h4>
<div className="flex-container">
{sounds.map((sound, idx) => (
<div
className="flex-container flex-column"
key={sound.name}
>
<div
onClick={() => onSoundClick(idx)}
className={classNames(style.addBtn)}
>
{sound.name}
</div>
<DeleteBtn idx={idx} type={'sound'} />
</div>
))}
{sounds.length < 20 && (
<div
onClick={() => onSoundClick()}
className={classNames(style.addBtn, style.addBtnEmpty)}
/>
)}
</div>
<h4> {icons.length}/10 </h4>
<div className="flex-container">
{icons.map((icon, idx) => (
<div className="flex-container flex-column" key={icon.name}>
<div
onClick={() => onIconClick(idx)}
className={classNames(style.addBtn)}
>
{/* @ts-ignore*/}
<img src={icon.content} alt="" />
</div>
<DeleteBtn idx={idx} type={'icon'} />
</div>
))}
{icons.length < 10 && (
<div
onClick={() => onIconClick()}
className={classNames(style.addBtn, style.addBtnEmpty)}
/>
)}
</div>
<div className="flex-spacer" />
{genLink && (
<div className="flex-container flex-column">
<QRCodeCanvas id="qrCode" value={genLink} size={300} />
<button
onClick={() =>
captureElement('qrCode', customThemeInfo.title)
}
className="primary"
>
</button>
<div>{genLink}</div>
<button onClick={() => copy(genLink)} className="primary">
</button>
<InputContainer label={'标题'} required>
<input placeholder={'请输入标题'} />
</InputContainer>
<InputContainer label={'描述'}>
<input placeholder={'请输入描述'} />
</InputContainer>
<InputContainer label={'BGM'}>
<input type={'file'} />
<input placeholder={'或者输入https外链'} />
</InputContainer>
<InputContainer label={'背景图'}>
<input type={'file'} />
<input placeholder={'或者输入https外链'} />
<div className={'flex-container flex-center flex-no-wrap'}>
<span></span>
<input type={'checkbox'} />
<div className={'flex-spacer'} />
<span></span>
<input type={'checkbox'} />
<div className={'flex-spacer'} />
<span></span>
<input type={'color'} value="#fff" />
</div>
)}
{configError && <div className={style.error}>{configError}</div>}
<div className={style.error}>
访广😭
fork进行部署
<a
href="https://github.com/StreakingMan/solvable-sheep-game"
target="_blank"
rel="noreferrer"
style={{ textDecoration: 'underline' }}
<div className={style.tip}>
使使
</div>
</InputContainer>
<InputContainer label={'关卡数'}>
<input
type={'number'}
placeholder={'最低5关最高...理论上无限默认为50'}
/>
</InputContainer>
<InputContainer label={'音效素材'} required>
<div className={'flex-container flex-left-center'}>
{customTheme.sounds.map((sound, idx) => {
return (
<div key={sound.name} className={style.soundItem}>
<audio src={sound.src} controls />
<div className={style.inner}>
<span>{sound.name}</span>
<CloseIcon fill={'#fff'} />
</div>
</div>
);
})}
</div>
<input
placeholder={'输入音效名称'}
onChange={(event) =>
setEditSound({
name: event.target.value,
src: editSound.src,
})
}
/>
<input type={'file'} />
<input
placeholder={'或者输入https外链'}
onChange={(event) =>
setEditSound({
src: event.target.value,
name: editSound.name,
})
}
/>
<button
onClick={() =>
setCustomTheme({
...customTheme,
sounds: [...customTheme.sounds, editSound],
})
}
>
github仓库地址
</a>
</div>
<div className="flex-container">
<button className="flex-grow" onClick={onPreviewClick}>
</button>
{/*<button className="flex-grow" onClick={onGenQrLinkClick}>
&{uploading && '...'}
</button>*/}
<button className="flex-grow" onClick={closeMethod}>
</button>
</div>
{/*添加弹窗*/}
<div
className={classNames(
style.addDialog,
addDialog.show && style.addDialogShow,
'flex-container flex-column'
)}
>
<div className="flex-container flex-center">
<input
ref={(ref) => ref && (inputRefMap.current.name = ref)}
className="flex-grow"
placeholder="唯一名称"
onChange={(e) =>
setAddDialog({
...addDialog,
[`${addDialog.type}Form`]: {
...addDialog[`${addDialog.type}Form`],
name: e.target.value,
},
})
}
/>
</InputContainer>
<InputContainer label={'图标素材'} required>
<div className={'flex-container flex-left-center'}>
{customTheme.icons.map((icon, idx) => {
return <div key={icon.name}>{icon.name}</div>;
})}
</div>
<div className="flex-container flex-center">
<input
ref={(ref) => ref && (inputRefMap.current.link = ref)}
className="flex-grow"
placeholder="https://example.com/src.audioOrImage"
onChange={(e) =>
setAddDialog({
...addDialog,
[`${addDialog.type}Form`]: {
...addDialog[`${addDialog.type}Form`],
[addDialog.type === 'sound'
? 'src'
: 'content']: e.target.value,
},
})
}
/>
</div>
{addDialog.type === 'icon' && (
<>
<div className="flex-container flex-center">
<select
ref={(ref) =>
ref &&
(inputRefMap.current.clickSound = ref)
}
className="flex-grow"
onChange={(e) =>
setAddDialog({
...addDialog,
/*@ts-ignore*/
iconForm: {
...addDialog.iconForm,
clickSound: e.target.value,
},
})
}
>
<option value=""></option>
{sounds.map((s) => (
<option key={s.name} value={s.name}>
{s.name}
</option>
))}
</select>
</div>
<div className="flex-container flex-center">
<select
ref={(ref) =>
ref &&
(inputRefMap.current.tripleSound = ref)
}
className="flex-grow"
onChange={(e) =>
setAddDialog({
...addDialog,
/*@ts-ignore*/
iconForm: {
...addDialog.iconForm,
tripleSound: e.target.value,
},
})
}
>
<option value=""></option>
{sounds.map((s) => (
<option key={s.name} value={s.name}>
{s.name}
</option>
))}
</select>
</div>
</>
)}
{addDialog.error && (
<div className={style.error}>{addDialog.error}</div>
)}
<div className="flex-container">
<button className="flex-grow" onClick={closeAddDialog}>
</button>
<button
className="flex-grow primary"
onClick={onAddDialogSaveClick}
>
</button>
</div>
</div>
</InputContainer>
<InputContainer label={'操作音效'}></InputContainer>
<WxQrCode />
</div>
);
};

View File

@ -37,6 +37,16 @@
color: white;
cursor: pointer;
user-select: none;
svg {
transition: 0.3s;
}
&:hover {
svg {
transform: rotate(180deg);
}
}
}
&.open {

View File

@ -30,16 +30,13 @@ button {
}
}
input,
select {
border: 1px solid gray;
border-radius: 4px;
padding: 4px 8px;
}
input {
border: 1px solid gray;
border: 1px dashed rgb(0 0 0 / 30%);
border-radius: 4px;
padding: 8px 12px;
&::placeholder {
color: rgb(0 0 0 / 30%);
}
}

View File

@ -13,6 +13,11 @@
align-items: center;
}
.flex-left-center {
justify-content: flex-start;
align-items: center;
}
.flex-row {
flex-direction: row;
}