diff --git a/src/components/CloseIcon.tsx b/src/components/CloseIcon.tsx index 83286e7..f1e9b26 100644 --- a/src/components/CloseIcon.tsx +++ b/src/components/CloseIcon.tsx @@ -1,6 +1,9 @@ -import React, { FC } from 'react'; +import React, { FC, MouseEventHandler } from 'react'; -export const CloseIcon: FC<{ fill: string }> = ({ fill }) => { +export const CloseIcon: FC<{ fill: string; onClick?: MouseEventHandler }> = ({ + fill, + onClick, +}) => { return ( = ({ fill }) => { viewBox="0 0 13 14" fill="none" xmlns="http://www.w3.org/2000/svg" + onClick={onClick} > = ({ label, children, required }) => { return ( <> @@ -39,6 +43,16 @@ const InputContainer: FC<{ ); }; +interface CustomIcon extends Icon { + content: string; +} + +interface CustomTheme extends Theme { + icons: CustomIcon[]; +} + +const id = localStorage.getItem(CUSTOM_THEME_ID_STORAGE_KEY); + const ConfigDialog: FC<{ closeMethod: () => void; previewMethod: (theme: Theme) => void; @@ -46,8 +60,12 @@ const ConfigDialog: FC<{ // 错误提示 const [configError, setConfigError] = useState(''); // 生成链接 - const [genLink, setGenLink] = useState(''); - const [customTheme, setCustomTheme] = useState>({ + const [genLink, setGenLink] = useState( + id ? `${location.origin}?customTheme=${id}` : '' + ); + + // 主题大对象 + const [customTheme, setCustomTheme] = useState({ title: '', sounds: [], icons: new Array(10).fill(0).map(() => ({ @@ -57,9 +75,174 @@ const ConfigDialog: FC<{ 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 [editSound, setEditSound] = useState({ name: '', src: '' }); + // 音效 + const [newSound, setNewSound] = useState({ name: '', src: '' }); + const [soundError, setSoundError] = useState(''); + 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(''); + const [backgroundError, setBackgroundError] = useState(''); + const [iconErrors, setIconErrors] = useState( + new Array(10).fill('') + ); + // 文件体积校验开关 + const [enableFileSizeValidate, setEnableFileSizeValidate] = + useState( + 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(() => { @@ -76,23 +259,34 @@ const ConfigDialog: FC<{ } }, []); - // 生成主题 - const generateTheme: () => Promise> = async () => { - // TODO 校验 - const cloneTheme = JSON.parse(JSON.stringify(customTheme)); - wrapThemeDefaultSounds(cloneTheme); - return Promise.resolve(cloneTheme); + // 校验主题 + const validateTheme: () => Promise = 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}`); + + return Promise.resolve(''); }; // 预览 const onPreviewClick = () => { setConfigError(''); - generateTheme() - .then((theme) => { - previewMethod(theme); + validateTheme() + .then(() => { + const cloneTheme = JSON.parse(JSON.stringify(customTheme)); + wrapThemeDefaultSounds(cloneTheme); + previewMethod(cloneTheme); localStorage.setItem( CUSTOM_THEME_STORAGE_KEY, - JSON.stringify(theme) + JSON.stringify(customTheme) ); closeMethod(); }) @@ -101,62 +295,66 @@ const ConfigDialog: FC<{ }); }; - const [uploading, setUploading] = useState(false); // 生成二维码和链接 + const [uploading, setUploading] = useState(false); const onGenQrLinkClick = () => { if (uploading) return; + if (!enableFileSizeValidate) + return setConfigError('请先开启文件大小校验'); setUploading(true); setConfigError(''); - generateTheme() - .then((theme) => { - // 五分钟能只能上传一次 - const lastUploadTime = localStorage.getItem( - LAST_UPLOAD_TIME_STORAGE_KEY - ); - if ( - lastUploadTime && - new Date().getTime() - Number(lastUploadTime) < - 1000 * 60 * 5 - ) { - setConfigError( - '五分钟内只能上传一次(用的人有点多十分抱歉😭),先保存预览看看效果把~' - ); - setUploading(false); - return; - } - - const stringify = JSON.stringify(theme); + 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'); + if (id) query.set('id', id); + // Bmob上限 384563 query.set('content', stringify); query .save() .then((res) => { - //@ts-ignore - const link = `${location.origin}?customTheme=${res.objectId}`; - setGenLink(link); - localStorage.setItem( - LAST_UPLOAD_TIME_STORAGE_KEY, - new Date().getTime().toString() - ); + if (!id) { + localStorage.setItem( + CUSTOM_THEME_ID_STORAGE_KEY, + //@ts-ignore + res.objectId + ); + } + setTimeout(() => { + setGenLink( + `${location.origin}?customTheme=${ + /*@ts-ignore*/ + res.objectId || id + }` + ); + }, 3000); }) - .catch(({ error }) => { - setConfigError(error); - setGenLink(''); + .catch(({ error, code }) => { + setTimeout(() => { + setConfigError( + code === 10007 + ? '上传数据长度已超过bmob的限制' + : error + ); + }, 3000); }) .finally(() => { - setUploading(false); + setTimeout(() => { + setUploading(false); + }, 3000); }); }) .catch((e) => { - setConfigError(e); - setGenLink(''); - setUploading(false); + setTimeout(() => { + setConfigError(e); + setUploading(false); + }, 3000); }); }; - // TODO HTML有点臭长了,待优化 - // @ts-ignore return (
@@ -165,39 +363,120 @@ const ConfigDialog: FC<{

自定义主题

- + updateCustomTheme('title', e.target.value)} + /> - + updateCustomTheme('desc', e.target.value)} + /> - - +
+ 接口上传体积有限制,上传文件请全力压缩到80k以下 +
+ + onFileChange({ + type: 'bgm', + file: e.target.files?.[0], + }) + } + /> + {bgmError &&
{bgmError}
} + updateCustomTheme('bgm', e.target.value)} + /> + {customTheme.bgm &&
- - +
+ 接口上传体积有限制,上传文件请全力压缩到80k以下 +
+ + onFileChange({ + type: 'background', + file: e.target.files?.[0], + }) + } + /> + {backgroundError && ( +
{backgroundError}
+ )} +
+ + updateCustomTheme('background', e.target.value) + } + /> + {customTheme.background && ( + 加载失败 + )} +
毛玻璃 - + + updateCustomTheme( + 'backgroundBlur', + e.target.checked + ) + } + />
深色 - + + updateCustomTheme('dark', e.target.checked) + } + />
纯色 - + + updateCustomTheme('backgroundColor', e.target.value) + } + />
使用图片或者纯色作为背景,图片可开启毛玻璃效果。如果你使用了深色的图片和颜色,请开启深色模式,此时标题等文字将变为亮色
- + + updateCustomTheme('maxLevel', Number(e.target.value)) + } /> - +
{customTheme.sounds.map((sound, idx) => { return ( @@ -205,7 +484,10 @@ const ConfigDialog: FC<{
); @@ -213,43 +495,177 @@ const ConfigDialog: FC<{
- setEditSound({ - name: event.target.value, - src: editSound.src, + value={newSound.name} + onChange={(e) => onNewSoundChange('name', e.target.value)} + /> +
+ 接口上传体积有限制,上传文件请全力压缩到10k以下 +
+ + onFileChange({ + type: 'sound', + file: e.target.files?.[0], }) } /> - - setEditSound({ - src: event.target.value, - name: editSound.name, - }) - } + value={newSound.src} + onChange={(e) => onNewSoundChange('src', e.target.value)} /> - + {soundError && ( +
{soundError}
+ )} + -
- {customTheme.icons.map((icon, idx) => { - return
{icon.name}
; - })} +
+ 接口上传体积有限制,上传文件请全力压缩到5k以下,推荐尺寸56*56
- ?? + {customTheme.icons.map((icon, idx) => ( +
+ +
+ + onFileChange({ + type: 'icon', + file: e.target.files?.[0], + idx, + }) + } + /> +
+ { + if (!linkReg.test(e.target.value)) + setIconErrors( + makeIconErrors( + idx, + '请输入https外链' + ) + ); + }} + onChange={(e) => + updateIcons('content', e.target.value, idx) + } + /> + {iconErrors[idx] && ( +
+ {iconErrors[idx]} +
+ )} +
+
+ + +
+
+
+ ))} + {/*??*/} + + {genLink && ( +
+ + +
{genLink}
+ +
+ )} +
+ 接口上传内容总体积有限制,上传文件失败请进一步压缩文件,或者使用外链(自行搜索【免费图床】【免费mp3外链】【对象存储服务】等关键词)。 + 本地整活,勾选右侧关闭文件大小校验👉 + + setEnableFileSizeValidate(!e.target.checked) + } + /> + (谨慎操作,单文件不超过1M为宜,文件过大可能导致崩溃,介时请刷新浏览器,) +
+ {configError &&
{configError}
} +
+ + +
); }; diff --git a/src/components/Game.tsx b/src/components/Game.tsx index aeed5bd..5ffd646 100644 --- a/src/components/Game.tsx +++ b/src/components/Game.tsx @@ -10,6 +10,7 @@ import { LAST_LEVEL_STORAGE_KEY, LAST_SCORE_STORAGE_KEY, LAST_TIME_STORAGE_KEY, + linkReg, randomString, waitTimeout, } from '../utils'; @@ -132,8 +133,7 @@ const Symbol: FC = ({ x, y, icon, isCover, status, onClick }) => { style={{ opacity: isCover ? 0.4 : 1 }} > {typeof icon.content === 'string' ? ( - icon.content.startsWith('http') || - icon.content.startsWith('/') ? ( + linkReg.test(icon.content) ? ( /*图片地址*/ ) : ( diff --git a/src/components/ThemeChanger.module.scss b/src/components/ThemeChanger.module.scss index 651ff06..e0719c9 100644 --- a/src/components/ThemeChanger.module.scss +++ b/src/components/ThemeChanger.module.scss @@ -74,7 +74,7 @@ right: 8px; bottom: 8px; transition: 0.5s; - height: 250px; + height: 280px; backdrop-filter: blur(10px); border-radius: 8px; background-color: rgb(0 0 0 / 30%); diff --git a/src/components/WxQrCode.module.scss b/src/components/WxQrCode.module.scss index 9e5c7c9..5f42678 100644 --- a/src/components/WxQrCode.module.scss +++ b/src/components/WxQrCode.module.scss @@ -11,6 +11,7 @@ &Title { opacity: 0.8; width: 100%; + font-size: 14px; } &Item { diff --git a/src/styles/global.scss b/src/styles/global.scss index ffa9e44..264a363 100644 --- a/src/styles/global.scss +++ b/src/styles/global.scss @@ -24,6 +24,7 @@ button { padding: 8px 16px; border-radius: 8px; background-color: #3338; + cursor: pointer; &.primary { background-color: #747bff; diff --git a/src/utils.ts b/src/utils.ts index 0f5e1ab..4e8f99e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,12 +4,15 @@ import { getDefaultTheme } from './themes/default'; export const LAST_LEVEL_STORAGE_KEY = 'lastLevel'; export const LAST_SCORE_STORAGE_KEY = 'lastScore'; export const LAST_TIME_STORAGE_KEY = 'lastTime'; -export const LAST_UPLOAD_TIME_STORAGE_KEY = 'lastUploadTime'; +export const CUSTOM_THEME_ID_STORAGE_KEY = 'customThemeId'; export const CUSTOM_THEME_STORAGE_KEY = 'customTheme'; +export const CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY = 'customThemeFileValidate'; export const DEFAULT_BGM_STORAGE_KEY = 'defaultBgm'; export const DEFAULT_TRIPLE_SOUND_STORAGE_KEY = 'defaultTripleSound'; export const DEFAULT_CLICK_SOUND_STORAGE_KEY = 'defaultClickSound'; +export const linkReg = /^(https|data):+/; + export const randomString: (len: number) => string = (len) => { const pool = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; let res = ''; @@ -106,7 +109,35 @@ export const wrapThemeDefaultSounds: (theme: Theme) => void = (theme) => { } }; +export const deleteThemeUnusedSounds = (theme: Theme) => { + const usedSounds = new Set(); + for (const icon of theme.icons) { + usedSounds.add(icon.clickSound); + usedSounds.add(icon.tripleSound); + } + theme.sounds = theme.sounds.filter((s) => usedSounds.has(s.name)); +}; + export const domRelatedOptForTheme = (theme: Theme) => { document.body.style.backgroundColor = theme.backgroundColor || 'white'; document.body.style.color = theme.dark ? 'white' : 'rgb(0 0 0 / 60%)'; }; + +export const getFileBase64String: (file: File) => Promise = ( + file: File +) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onloadend = (e) => { + if (e.target?.result) { + resolve(e.target.result.toString()); + } else { + reject('读取文件内容为空'); + } + }; + reader.onerror = () => { + reject('读取文件失败'); + }; + }); +};