Files
solvable-sheep-game/src/components/ConfigDialog.tsx
T

729 lines
28 KiB
TypeScript

import React, { FC, ReactNode, useEffect, 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,
CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY,
LAST_CUSTOM_THEME_ID_STORAGE_KEY,
CUSTOM_THEME_STORAGE_KEY,
deleteThemeUnusedSounds,
getFileBase64String,
linkReg,
randomString,
wrapThemeDefaultSounds,
LAST_UPLOAD_TIME_STORAGE_KEY,
} from '../utils';
import { copy } from 'clipboard';
import { CloseIcon } from './CloseIcon';
import WxQrCode from './WxQrCode';
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>
</>
);
};
interface CustomIcon extends Icon {
content: string;
}
interface CustomTheme extends Theme<any> {
icons: CustomIcon[];
}
const ConfigDialog: FC<{
closeMethod: () => void;
previewMethod: (theme: Theme<string>) => void;
}> = ({ closeMethod, previewMethod }) => {
// 错误提示
const [configError, setConfigError] = useState<string>('');
// 生成链接
const [genLink, setGenLink] = useState<string>('');
// 主题大对象
const [customTheme, setCustomTheme] = useState<CustomTheme>({
title: '',
sounds: [],
pure: false,
icons: new Array(10).fill(0).map(() => ({
name: randomString(4),
content: '',
clickSound: '',
tripleSound: '',
})),
});
function updateCustomTheme(key: keyof CustomTheme, value: any) {
if (['sounds', 'icons'].includes(key)) {
if (Array.isArray(value)) {
setCustomTheme({
...customTheme,
[key]: [...value],
});
} else {
setCustomTheme({
...customTheme,
[key]: [...customTheme[key as 'sounds' | 'icons'], value],
});
}
} else {
setCustomTheme({
...customTheme,
[key]: value,
});
}
}
useEffect(() => {
console.log(customTheme);
}, [customTheme]);
// 音效
const [newSound, setNewSound] = useState<Sound>({ name: '', src: '' });
const [soundError, setSoundError] = useState<string>('');
const onNewSoundChange = (key: keyof Sound, value: string) => {
setNewSound({
...newSound,
[key]: value,
});
};
const onAddNewSoundClick = () => {
setSoundError('');
let error = '';
if (!linkReg.test(newSound.src)) error = '请输入https链接';
if (!newSound.name) error = '请输入音效名称';
if (customTheme.sounds.find((s) => s.name === newSound.name))
error = '名称已存在';
if (error) {
setSoundError(error);
} else {
updateCustomTheme('sounds', newSound);
setNewSound({ name: '', src: '' });
}
};
const onDeleteSoundClick = (idx: number) => {
const deleteSoundName = customTheme.sounds[idx].name;
const findIconUseIdx = customTheme.icons.findIndex(
({ clickSound, tripleSound }) =>
[clickSound, tripleSound].includes(deleteSoundName)
);
if (findIconUseIdx !== -1) {
return setSoundError(
`第${findIconUseIdx + 1}项图标有使用该音效,请取消后再删除`
);
}
const newSounds = customTheme.sounds.slice();
newSounds.splice(idx, 1);
updateCustomTheme('sounds', newSounds);
};
// 本地文件选择
const [bgmError, setBgmError] = useState<string>('');
const [backgroundError, setBackgroundError] = useState<string>('');
const [iconErrors, setIconErrors] = useState<string[]>(
new Array(10).fill('')
);
// 文件体积校验开关
const [enableFileSizeValidate, setEnableFileSizeValidate] =
useState<boolean>(
localStorage.getItem(CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY) !==
'false'
);
useEffect(() => {
localStorage.setItem(
CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY,
enableFileSizeValidate + ''
);
}, [enableFileSizeValidate]);
const makeIconErrors = (idx: number, error: string) =>
new Array(10)
.fill('')
.map((item, _idx) => (idx === _idx ? error : iconErrors[_idx]));
const onFileChange: (props: {
type: 'bgm' | 'background' | 'sound' | 'icon';
file?: File;
idx?: number;
}) => void = ({ type, file, idx }) => {
if (!file) return;
switch (type) {
case 'bgm':
setBgmError('');
if (enableFileSizeValidate && file.size > 80 * 1024) {
return setBgmError('请选择80k以内全损音质的文件');
}
getFileBase64String(file)
.then((res) => {
updateCustomTheme('bgm', res);
})
.catch((e) => {
setBgmError(e);
});
break;
case 'background':
setBackgroundError('');
if (enableFileSizeValidate && file.size > 80 * 1024) {
return setBackgroundError('请选择80k以内全损画质的图片');
}
getFileBase64String(file)
.then((res) => {
updateCustomTheme('background', res);
})
.catch((e) => {
setBackgroundError(e);
});
break;
case 'sound':
setSoundError('');
if (enableFileSizeValidate && file.size > 10 * 1024) {
return setSoundError('请选择10k以内的音频文件');
}
getFileBase64String(file)
.then((res) => {
onNewSoundChange('src', res);
})
.catch((e) => {
setSoundError(e);
});
break;
case 'icon':
if (idx == null) return;
setIconErrors(makeIconErrors(idx, ''));
if (enableFileSizeValidate && file.size > 5 * 1024) {
return setIconErrors(
makeIconErrors(idx, '请选择5k以内的图片文件')
);
}
getFileBase64String(file)
.then((res) => {
updateCustomTheme(
'icons',
customTheme.icons.map((icon, _idx) =>
_idx === idx ? { ...icon, content: res } : icon
)
);
})
.catch((e) => {
setIconErrors(makeIconErrors(idx, e));
});
break;
}
};
// 图标更新
const updateIcons = (key: keyof CustomIcon, value: string, idx: number) => {
const newIcons = customTheme.icons.map((icon, _idx) =>
_idx === idx
? {
...icon,
[key]: value,
}
: icon
);
updateCustomTheme('icons', newIcons);
};
// 初始化
useEffect(() => {
const lastId = localStorage.getItem(LAST_CUSTOM_THEME_ID_STORAGE_KEY);
lastId && setGenLink(`${location.origin}?customTheme=${lastId}`);
try {
const configString = localStorage.getItem(CUSTOM_THEME_STORAGE_KEY);
if (configString) {
const parseRes = JSON.parse(configString);
if (typeof parseRes === 'object') {
setCustomTheme(parseRes);
}
}
} catch (e) {
console.log(e);
}
}, []);
// 校验主题
const validateTheme: () => Promise<string> = async () => {
// 校验
if (!customTheme.title) return Promise.reject('请输入标题');
if (customTheme.bgm && !linkReg.test(customTheme.bgm))
return Promise.reject('bgm请输入https链接');
if (customTheme.background && !linkReg.test(customTheme.background))
return Promise.reject('背景图请输入https链接');
if (!customTheme.maxLevel || customTheme.maxLevel < 5)
return Promise.reject('请输入大于5的关卡数');
const findIconError = iconErrors.find((i) => !!i);
if (findIconError)
return Promise.reject(`图标素材有错误:${findIconError}`);
const findUnfinishedIconIdx = customTheme.icons.findIndex(
(icon) => !icon.content
);
if (findUnfinishedIconIdx !== -1) {
setIconErrors(makeIconErrors(findUnfinishedIconIdx, '请填写链接'));
return Promise.reject(
`第${findUnfinishedIconIdx + 1}图标素材未完成`
);
}
return Promise.resolve('');
};
// 预览
const onPreviewClick = () => {
setConfigError('');
validateTheme()
.then(() => {
const cloneTheme = JSON.parse(JSON.stringify(customTheme));
wrapThemeDefaultSounds(cloneTheme);
previewMethod(cloneTheme);
localStorage.setItem(
CUSTOM_THEME_STORAGE_KEY,
JSON.stringify(customTheme)
);
closeMethod();
})
.catch((e) => {
setConfigError(e);
});
};
// 生成二维码和链接
const [uploading, setUploading] = useState<boolean>(false);
const onGenQrLinkClick = () => {
if (uploading) return;
if (!enableFileSizeValidate)
return setConfigError('请先开启文件大小校验');
let passTime = Number.MAX_SAFE_INTEGER;
const lastUploadTime = localStorage.getItem(
LAST_UPLOAD_TIME_STORAGE_KEY
);
if (lastUploadTime) {
passTime = Date.now() - Number(lastUploadTime);
}
if (passTime < 1000 * 60 * 15) {
return setConfigError(
`为节省请求数,15分钟内只能生成一次二维码,还剩大约${
15 - Math.round(passTime / 1000 / 60)
}分钟,先本地预览调整下吧~`
);
}
setUploading(true);
setConfigError('');
validateTheme()
.then(() => {
const cloneTheme = JSON.parse(JSON.stringify(customTheme));
deleteThemeUnusedSounds(cloneTheme);
const stringify = JSON.stringify(cloneTheme);
localStorage.setItem(CUSTOM_THEME_STORAGE_KEY, stringify);
const query = Bmob.Query('config');
query.set('content', stringify);
query
.save()
.then((res) => {
localStorage.setItem(
LAST_CUSTOM_THEME_ID_STORAGE_KEY,
//@ts-ignore
res.objectId
);
localStorage.setItem(
LAST_UPLOAD_TIME_STORAGE_KEY,
Date.now().toString()
);
setTimeout(() => {
setGenLink(
`${location.origin}?customTheme=${
/*@ts-ignore*/
res.objectId || id
}`
);
}, 3000);
})
.catch(({ error, code }) => {
setTimeout(() => {
setConfigError(
code === 10007
? '上传数据长度已超过bmob的限制'
: error
);
}, 3000);
})
.finally(() => {
setTimeout(() => {
setUploading(false);
}, 3000);
});
})
.catch((e) => {
setConfigError(e);
setUploading(false);
});
};
// 彩蛋
const [pureClickTime, setPureClickTime] = useState<number>(0);
useEffect(() => {
updateCustomTheme(
'pure',
pureClickTime % 5 === 0 && pureClickTime !== 0
);
}, [pureClickTime]);
return (
<div className={classNames(style.dialog)}>
<div className={style.closeBtn} onClick={closeMethod}>
<CloseIcon fill={'#fff'} />
</div>
<h2>自定义主题</h2>
<InputContainer label={'标题'} required>
<input
placeholder={'请输入标题'}
value={customTheme.title}
onChange={(e) => updateCustomTheme('title', e.target.value)}
/>
</InputContainer>
<InputContainer label={'描述'}>
<input
placeholder={'请输入描述'}
value={customTheme.desc || ''}
onChange={(e) => updateCustomTheme('desc', e.target.value)}
/>
</InputContainer>
<InputContainer label={'BGM'}>
<div className={style.tip}>
接口上传体积有限制,上传文件请全力压缩到80k以下
</div>
<input
type={'file'}
accept={'.mp3'}
onChange={(e) =>
onFileChange({
type: 'bgm',
file: e.target.files?.[0],
})
}
/>
{bgmError && <div className={style.errorTip}>{bgmError}</div>}
<input
placeholder={'或者输入https外链'}
value={customTheme.bgm || ''}
onChange={(e) => updateCustomTheme('bgm', e.target.value)}
/>
{customTheme.bgm && <audio src={customTheme.bgm} controls />}
</InputContainer>
<InputContainer label={'背景图'}>
<div className={style.tip}>
接口上传体积有限制,上传文件请全力压缩到80k以下
</div>
<input
type={'file'}
accept={'.jpg,.png,.gif'}
onChange={(e) =>
onFileChange({
type: 'background',
file: e.target.files?.[0],
})
}
/>
{backgroundError && (
<div className={style.errorTip}>{backgroundError}</div>
)}
<div className={'flex-container flex-center'}>
<input
placeholder={'或者输入https外链'}
value={customTheme.background || ''}
onChange={(e) =>
updateCustomTheme('background', e.target.value)
}
/>
{customTheme.background && (
<img
alt="加载失败"
src={customTheme.background}
className={style.imgPreview}
/>
)}
</div>
<div className={'flex-container flex-center flex-wrap'}>
<div className={'flex-spacer flex-container flex-center'}>
<span>毛玻璃</span>
<input
type={'checkbox'}
checked={!!customTheme.backgroundBlur}
onChange={(e) =>
updateCustomTheme(
'backgroundBlur',
e.target.checked
)
}
/>
</div>
<div className={'flex-spacer flex-container flex-center'}>
<span>深色</span>
<input
type={'checkbox'}
checked={!!customTheme.dark}
onChange={(e) =>
updateCustomTheme('dark', e.target.checked)
}
/>
</div>
<div className={'flex-spacer flex-container flex-center'}>
<span>纯色</span>
<input
type={'color'}
value={customTheme.backgroundColor || '#ffffff'}
onChange={(e) =>
updateCustomTheme(
'backgroundColor',
e.target.value
)
}
/>
</div>
</div>
<div className={style.tip}>
使用图片或者纯色作为背景,图片可开启毛玻璃效果。如果你使用了深色的图片和颜色,请开启深色模式,此时标题等文字将变为亮色
</div>
</InputContainer>
<InputContainer label={'关卡数'} required>
<input
type={'number'}
placeholder={'最低5关,最高...理论上无限'}
value={customTheme.maxLevel || ''}
onChange={(e) =>
updateCustomTheme('maxLevel', Number(e.target.value))
}
/>
</InputContainer>
<InputContainer label={'音效素材'}>
<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'}
onClick={() => onDeleteSoundClick(idx)}
/>
</div>
</div>
);
})}
</div>
<input
placeholder={'输入音效名称'}
value={newSound.name}
onChange={(e) => onNewSoundChange('name', e.target.value)}
/>
<div className={style.tip}>
接口上传体积有限制,上传文件请全力压缩到10k以下
</div>
<input
type={'file'}
accept={'.mp3'}
onChange={(e) =>
onFileChange({
type: 'sound',
file: e.target.files?.[0],
})
}
/>
<input
placeholder={'或者输入https外链'}
value={newSound.src}
onChange={(e) => onNewSoundChange('src', e.target.value)}
/>
{soundError && (
<div className={style.errorTip}>{soundError}</div>
)}
<button onClick={onAddNewSoundClick}>添加音效</button>
</InputContainer>
<InputContainer label={'图标素材'} required>
<div className={style.tip}>
接口上传体积有限制,上传文件请全力压缩到5k以下,推荐尺寸56*56
</div>
</InputContainer>
{customTheme.icons.map((icon, idx) => (
<div key={icon.name} className={style.iconInputGroup}>
<img
alt=""
className={style.iconPreview}
src={icon.content}
/>
<div className={style.iconInput}>
<input
type={'file'}
accept={'.jpg,.png,.gif'}
onChange={(e) =>
onFileChange({
type: 'icon',
file: e.target.files?.[0],
idx,
})
}
/>
<div
className={
'flex-container flex-center flex-no-wrap'
}
style={{ wordBreak: 'keep-all' }}
>
<input
placeholder={'或者输入https外链'}
value={customTheme.icons[idx].content}
onBlur={(e) => {
setIconErrors(
makeIconErrors(
idx,
linkReg.test(e.target.value)
? ''
: '请输入https外链'
)
);
}}
onChange={(e) =>
updateIcons('content', e.target.value, idx)
}
/>
{iconErrors[idx] && (
<div className={style.errorTip}>
{iconErrors[idx]}
</div>
)}
</div>
<div className={'flex-container'}>
<select
className={'flex-grow'}
value={customTheme.icons[idx].clickSound}
onChange={(e) =>
updateIcons(
'clickSound',
e.target.value,
idx
)
}
>
<option value="">默认点击音效</option>
{customTheme.sounds.map((sound) => (
<option key={sound.name} value={sound.name}>
{sound.name}
</option>
))}
</select>
<select
className={'flex-grow'}
value={customTheme.icons[idx].tripleSound}
onChange={(e) =>
updateIcons(
'tripleSound',
e.target.value,
idx
)
}
>
<option value="">默认三连音效</option>
{customTheme.sounds.map((sound) => (
<option key={sound.name} value={sound.name}>
{sound.name}
</option>
))}
</select>
</div>
</div>
</div>
))}
{/*<InputContainer label={'操作音效'}>??</InputContainer>*/}
{genLink && (
<div className={'flex-container flex-center flex-column'}>
<QRCodeCanvas
id="qrCode"
value={genLink}
size={300}
className={classNames(
style.qrCode,
uploading && style.uploading
)}
/>
<button
onClick={() =>
captureElement(
'qrCode',
`${customTheme.title}-${localStorage.getItem(
LAST_CUSTOM_THEME_ID_STORAGE_KEY
)}`
)
}
className="primary"
>
下载二维码
</button>
<div style={{ fontSize: 12 }}>{genLink}</div>
<button onClick={() => copy(genLink)} className="primary">
复制链接
</button>
</div>
)}
<div className={style.tip}>
接口上传内容总体积有限制,上传文件失败请进一步压缩文件,或者使用外链(自行搜索【免费图床】【免费mp3外链】【对象存储服务】等关键词)。
本地整活,勾选右侧关闭文件大小校验👉
<input
type={'checkbox'}
checked={!enableFileSizeValidate}
onChange={(e) =>
setEnableFileSizeValidate(!e.target.checked)
}
/>
(谨慎操作,单文件不超过1M为宜,文件过大可能导致崩溃,介时请刷新浏览器)
</div>
{configError && <div className={style.errorTip}>{configError}</div>}
{customTheme.pure && (
<div className={style.tip}>
🎉🎉🎉恭喜发现彩蛋,生成的主题将开启纯净模式~
</div>
)}
<WxQrCode onClick={() => setPureClickTime(pureClickTime + 1)} />
<div className={'flex-container'}>
<button
className={'primary flex-grow'}
onClick={onPreviewClick}
>
保存并预览
</button>
<button
className={classNames(
'primary flex-grow',
style.uploadBtn,
uploading && style.uploading
)}
onClick={onGenQrLinkClick}
>
生成二维码&链接
</button>
</div>
</div>
);
};
export default ConfigDialog;