feat: 自定义主题

This commit is contained in:
streakingman
2022-09-24 23:29:35 +08:00
parent e287398572
commit e35ddfa44e
12 changed files with 885 additions and 82 deletions
+41 -53
View File
@@ -10,21 +10,23 @@
margin: 0 auto;
}
.scene-container {
width: 100%;
padding-bottom: 100%;
position: relative;
margin: 10% 0;
}
.scene {
&-container {
width: 100%;
padding-bottom: 100%;
position: relative;
margin: 10% 0;
}
.scene-inner {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
overflow: visible;
font-size: 28px;
&-inner {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
overflow: visible;
font-size: 28px;
}
}
.symbol {
@@ -35,28 +37,28 @@
left: 0;
top: 0;
border-radius: 8px;
}
.symbol-inner {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
border: 2px solid #444;
transition: 0.3s;
overflow: hidden;
user-select: none;
}
&-inner {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
border: 2px solid #444;
transition: 0.3s;
overflow: hidden;
user-select: none;
.symbol-inner img {
width: 100%;
height: 100%;
object-fit: cover;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
.queue-container {
@@ -67,25 +69,6 @@
margin-bottom: 16px;
}
.flex-container {
display: flex;
gap: 8px;
}
.flex-center {
justify-content: center;
align-items: center;
}
.flex-grow {
flex-grow: 1;
}
.flex-between {
justify-content: space-between;
align-items: center;
}
.modal {
position: fixed;
width: 100vw;
@@ -108,3 +91,8 @@
width: 36px;
height: 36px;
}
.zhenghuo-button {
width: 100%;
margin-top: 8px;
}
+62 -12
View File
@@ -6,9 +6,14 @@ import React, {
useState,
} from 'react';
import './App.css';
import './App.scss';
import { GithubIcon } from './components/GithubIcon';
import { parseThemePath, randomString, waitTimeout } from './utils';
import {
parsePathCustomTheme,
parsePathThemeName,
randomString,
waitTimeout,
} from './utils';
import { defaultTheme } from './themes/default';
import { Icon, Theme } from './themes/interface';
import { fishermanTheme } from './themes/fisherman';
@@ -18,9 +23,10 @@ import { pddTheme } from './themes/pdd';
import { BeiAn } from './components/BeiAn';
import { Info } from './components/Info';
import { owTheme } from './themes/ow';
import { ConfigDialog } from './components/ConfigDialog';
// 主题
const themes: Theme<any>[] = [
// 内置主题
const builtInThemes: Theme<any>[] = [
defaultTheme,
fishermanTheme,
jinlunTheme,
@@ -156,8 +162,15 @@ const Symbol: FC<SymbolProps> = ({ x, y, icon, isCover, status, onClick }) => {
style={{ opacity: isCover ? 0.5 : 1 }}
>
{typeof icon.content === 'string' ? (
<i>{icon.content}</i>
icon.content.startsWith('http') ? (
/*图片外链*/
<img src={icon.content} alt="" />
) : (
/*字符表情*/
<i>{icon.content}</i>
)
) : (
/*ReactNode*/
icon.content
)}
</div>
@@ -166,12 +179,12 @@ const Symbol: FC<SymbolProps> = ({ x, y, icon, isCover, status, onClick }) => {
};
// 从url初始化主题
const themeFromPath: string = parseThemePath(location.href);
const themeFromPath: string = parsePathThemeName(location.href);
const customThemeFromPath = parsePathCustomTheme(location.href);
const App: FC = () => {
const [curTheme, setCurTheme] = useState<Theme<any>>(
themes.find((theme) => theme.name === themeFromPath) ?? defaultTheme
);
const [curTheme, setCurTheme] = useState<Theme<any>>(defaultTheme);
const [themes, setThemes] = useState<Theme<any>[]>(builtInThemes);
const [scene, setScene] = useState<Scene>(makeScene(1, curTheme.icons));
const [level, setLevel] = useState<number>(1);
@@ -182,6 +195,7 @@ const App: FC = () => {
const [finished, setFinished] = useState<boolean>(false);
const [tipText, setTipText] = useState<string>('');
const [animating, setAnimating] = useState<boolean>(false);
const [configDialogShow, setConfigDialogShow] = useState<boolean>(false);
// 音效
const soundRefMap = useRef<Record<string, HTMLAudioElement>>({});
@@ -200,6 +214,21 @@ const App: FC = () => {
}
}, [bgmOn]);
// 初始化主题
useEffect(() => {
if (customThemeFromPath) {
// 自定义主题
setThemes([...themes, customThemeFromPath]);
setCurTheme(customThemeFromPath);
} else if (themeFromPath) {
// 内置主题
setCurTheme(
themes.find((theme) => theme.name === themeFromPath) ??
defaultTheme
);
}
}, []);
// 主题切换
useEffect(() => {
// 初始化时不加载bgm
@@ -210,7 +239,8 @@ const App: FC = () => {
}, 300);
}
restart();
// 更改路径
// 更改路径query
if (customThemeFromPath) return;
history.pushState(
{},
curTheme.title,
@@ -420,12 +450,17 @@ const App: FC = () => {
setAnimating(false);
};
// 自定义整活
const customZhenghuo = (theme: Theme<string>) => {
setCurTheme(theme);
};
return (
<>
<h2>{curTheme.title}</h2>
<h6>
<p>
<GithubIcon />
</h6>
</p>
<h3 className="flex-container flex-center">
:
{/*TODO themes维护方式调整*/}
@@ -486,10 +521,18 @@ const App: FC = () => {
{/*<button onClick={test}>测试</button>*/}
</div>
<button
onClick={() => setConfigDialogShow(true)}
className="zhenghuo-button primary"
>
</button>
<Info />
<BeiAn />
{/*提示弹窗*/}
{finished && (
<div className="modal">
<h1>{tipText}</h1>
@@ -497,6 +540,13 @@ const App: FC = () => {
</div>
)}
{/*自定义主题弹窗*/}
<ConfigDialog
show={configDialogShow}
closeMethod={() => setConfigDialogShow(false)}
previewMethod={customZhenghuo}
/>
{/*bgm*/}
<button className="bgm-button" onClick={() => setBgmOn(!bgmOn)}>
{bgmOn ? '🔊' : '🔈'}
+100
View File
@@ -0,0 +1,100 @@
.dialog {
text-align: left;
&Wrapper {
z-index: 10;
position: fixed;
left: 50%;
top: 0;
width: calc(100% - 32px);
max-width: 500px;
bottom: 0;
transform: translateX(-50%) translateY(-100%);
opacity: 0;
background-color: white;
transition: 0.3s;
padding: 16px;
display: flex;
flex-direction: column;
@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%);
}
}
&Show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
.error {
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;
&Empty::before {
content: '+';
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;
background-color: white;
border-radius: 8px;
border: 2px solid #535bf2;
&Show {
transform: translateX(-50%) translateY(-50%);
opacity: 1;
}
}
}
.delete {
&Btn {
flex-grow: 1;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f9f9f9;
font-size: 1.5em;
color: #999;
span {
transform: rotate(45deg);
}
}
}
+532
View File
@@ -0,0 +1,532 @@
import React, { FC, useEffect, useRef, useState } from 'react';
import style from './ConfigDialog.module.scss';
import classNames from 'classnames';
import { Icon, Sound, Theme } from '../themes/interface';
import { defaultSounds } from '../themes/default';
import { QRCodeSVG } from 'qrcode.react';
const STORAGEKEY = 'customTheme';
let storageTheme: Theme<any>;
try {
const configString = localStorage.getItem(STORAGEKEY);
if (configString) {
const parseRes = JSON.parse(configString);
if (typeof parseRes === 'object') storageTheme = parseRes;
}
} catch (e) {
//
}
export const ConfigDialog: FC<{
show: boolean;
closeMethod: () => void;
previewMethod: (theme: Theme<string>) => void;
}> = ({ show, 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;
}>({ title: '', desc: '', bgm: '' });
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>('');
// 初始化
useEffect(() => {
if (storageTheme) {
const { title, desc, bgm, sounds, icons } = storageTheme;
setSounds(
sounds.filter(
(s) => !['triple', 'button-click'].includes(s.name)
)
);
setIcons(
storageTheme.icons.map((icon) => {
if (icon.clickSound === 'button-click')
icon.clickSound = '';
if (icon.tripleSound === 'triple') icon.tripleSound = '';
return icon;
})
);
setCustomThemeInfo({
title,
// @ts-ignore
desc,
bgm,
});
}
}, []);
// 音效保存
const saveSound = (sound: Sound, idx?: number) => {
if (!sound.src.startsWith('http')) return '请输入http/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('http')
)
return '请输入http/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 } = customThemeInfo;
if (bgm && bgm.startsWith('http'))
return Promise.reject('bgm请输入http/https链接');
if (!title) return Promise.reject('请填写标题');
if (icons.length !== 10) return Promise.reject('图片素材需要提供10张');
let hasDefaultMaterial = false;
const customIcons = icons.map((icon) => {
if (!icon.clickSound) {
hasDefaultMaterial = true;
icon.clickSound = 'button-click';
}
if (!icon.tripleSound) {
hasDefaultMaterial = true;
icon.tripleSound = 'triple';
}
return { ...icon };
});
const customSounds = sounds.map((sounds) => ({ ...sounds }));
if (hasDefaultMaterial) {
customSounds.push(...defaultSounds);
}
const customTheme: Theme<any> = {
name: `自定义-${title}`,
title,
desc,
bgm,
icons: customIcons,
sounds: customSounds,
};
return Promise.resolve(customTheme);
};
// 预览
const onPreviewClick = () => {
setConfigError('');
generateTheme()
.then((theme) => {
previewMethod(theme);
localStorage.setItem(STORAGEKEY, JSON.stringify(theme));
closeMethod();
})
.catch((e) => {
setConfigError(e);
});
};
// 生成二维码和链接
const onGenQrLinkClick = () => {
setConfigError('');
setGenLink('');
generateTheme()
.then((theme) => {
const stringify = JSON.stringify(theme);
localStorage.setItem(STORAGEKEY, stringify);
const link = `${
location.origin
}?customTheme=${encodeURIComponent(stringify)}`;
setGenLink(link);
})
.catch((e) => {
setConfigError(e);
});
};
// 删除按钮
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>
);
};
// @ts-ignore
return (
<div
className={classNames(
style.dialog,
style.dialogWrapper,
show && style.dialogShow,
'flex-container flex-container'
)}
>
<p>
<strong></strong>
</p>
{/*基本配置*/}
<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="可选 http(s)://example.com/src.audioOrImage"
className="flex-grow"
onChange={(e) =>
setCustomThemeInfo({
...customThemeInfo,
bgm: e.target.value,
})
}
/>
</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 && <textarea value={genLink} />}
{configError && <div className={style.error}>{configError}</div>}
<div className="flex-container">
<button className="flex-grow" onClick={onPreviewClick}>
</button>
<button className="flex-grow" onClick={onGenQrLinkClick}>
&
</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,
},
})
}
/>
</div>
<div className="flex-container flex-center">
<input
ref={(ref) => ref && (inputRefMap.current.link = ref)}
className="flex-grow"
placeholder="http(s)://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>
</div>
);
};
+2 -1
View File
@@ -1,7 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
import './styles/global.scss';
import './styles/utils.scss';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
+14 -8
View File
@@ -34,11 +34,17 @@ h1 {
}
select {
border: 2px solid gray;
border: 1px solid gray;
border-radius: 4px;
padding: 4px 8px;
}
input {
border: 1px solid gray;
border-radius: 4px;
padding: 8px 12px;
}
button {
border-radius: 8px;
border: 1px solid transparent;
@@ -51,15 +57,15 @@ button {
transition: border-color 0.25s;
word-break: keep-all;
outline: none;
}
button:hover {
border-color: #646cff;
}
&.primary {
background-color: #646cff;
color: white;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
&:hover:not(.primary) {
border-color: #646cff;
}
}
@media (prefers-color-scheme: light) {
+31
View File
@@ -0,0 +1,31 @@
.flex-container {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.flex-center {
justify-content: center;
align-items: center;
}
.flex-row {
flex-direction: row;
}
.flex-column {
flex-direction: column;
}
.flex-grow {
flex-grow: 1;
}
.flex-between {
justify-content: space-between;
align-items: center;
}
.flex-spacer {
flex: 1 1 auto;
}
+2 -1
View File
@@ -7,13 +7,14 @@ export interface Icon<T = string> {
tripleSound: T;
}
interface Sound<T = string> {
export interface Sound<T = string> {
name: T;
src: string;
}
type Operation = 'shift' | 'undo' | 'wash';
// TODO title name 冗余
export interface Theme<SoundNames> {
title: string;
desc?: ReactNode;
+25 -1
View File
@@ -1,3 +1,5 @@
import { Theme } from './themes/interface';
export const randomString: (len: number) => string = (len) => {
const pool = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let res = '';
@@ -16,8 +18,30 @@ export const waitTimeout: (timeout: number) => Promise<void> = (timeout) => {
});
};
export const parseThemePath: (url: string) => string = (url) => {
// 从url获取内置主题name
export const parsePathThemeName: (url: string) => string = (url) => {
const urlObj = new URL(url);
const params = urlObj.searchParams;
return decodeURIComponent(params.get('theme') || '默认');
};
// 从url解析自定义主题JSON
export const parsePathCustomTheme: (url: string) => Theme<string> | null = (
url
) => {
const urlObj = new URL(url);
const params = urlObj.searchParams;
const customThemeJsonString = params.get('customTheme');
if (!customThemeJsonString) return null;
try {
const parseTheme = JSON.parse(
decodeURIComponent(customThemeJsonString)
);
// TODO 解析内容校验
console.log(parseTheme);
return parseTheme;
} catch (e) {
console.log(e);
return null;
}
};