Type editor and schema viewer. Poppables and help icon
This commit is contained in:
@@ -1,11 +1,9 @@
|
||||
import { RecoilRoot } from 'recoil'
|
||||
import { TextMapper } from './components/Importer/text-mapper'
|
||||
import { SchemaBuilder } from './components/SchemaBuilder'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<RecoilRoot>
|
||||
{/* <TextMapper /> */}
|
||||
<SchemaBuilder />
|
||||
</RecoilRoot>
|
||||
)
|
||||
|
1
project-warstone/src/assets/icons/Help Icon copy.min.svg
Normal file
1
project-warstone/src/assets/icons/Help Icon copy.min.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" stroke="#fff" fill="#fff" height="28" width="28"><circle stroke-width="3" fill="none" stroke="inherit" r="12.5" cy="14" cx="14"/><path fill="inherit" d="M12.004 17.816v-.867c0-.531.074-1 .223-1.406.148-.414.386-.805.714-1.172.328-.375.762-.758 1.301-1.148.485-.344.871-.653 1.16-.926.297-.274.512-.543.645-.809.14-.273.21-.582.21-.925 0-.508-.187-.895-.562-1.16-.375-.266-.898-.4-1.57-.4s-1.34.106-2.004.317c-.656.211-1.324.489-2.004.832L8.84 7.586c.781-.438 1.629-.79 2.543-1.055.914-.273 1.914-.41 3-.41 1.672 0 2.965.402 3.879 1.207.922.797 1.382 1.813 1.382 3.047 0 .656-.105 1.227-.316 1.71a4.165 4.165 0 01-.937 1.337c-.414.406-.934.836-1.559 1.289-.469.344-.828.633-1.078.867-.25.235-.422.469-.516.703a2.356 2.356 0 00-.129.832v.703zm-.375 4.008c0-.734.2-1.25.598-1.547.406-.297.894-.445 1.464-.445.555 0 1.032.148 1.43.445.406.297.61.813.61 1.547 0 .703-.204 1.211-.61 1.524-.398.312-.875.468-1.43.468-.57 0-1.058-.156-1.464-.468-.399-.313-.598-.82-.598-1.524z"/></svg>
|
After Width: | Height: | Size: 1017 B |
70
project-warstone/src/assets/icons/Help Icon copy.svg
Normal file
70
project-warstone/src/assets/icons/Help Icon copy.svg
Normal file
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
|
||||
sodipodi:docname="Help Icon.svg"
|
||||
id="svg6"
|
||||
version="1.1"
|
||||
viewBox="0 0 28 28"
|
||||
stroke="#ffffff"
|
||||
fill="#ffffff"
|
||||
height="28"
|
||||
width="28">
|
||||
<metadata
|
||||
id="metadata12">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs10" />
|
||||
<sodipodi:namedview
|
||||
inkscape:current-layer="svg6"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:window-y="1432"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:cy="14"
|
||||
inkscape:cx="14"
|
||||
inkscape:zoom="27.222222"
|
||||
fit-margin-bottom="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-top="0"
|
||||
showgrid="false"
|
||||
id="namedview8"
|
||||
inkscape:window-height="1369"
|
||||
inkscape:window-width="3440"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0"
|
||||
guidetolerance="10"
|
||||
gridtolerance="10"
|
||||
objecttolerance="10"
|
||||
borderopacity="1"
|
||||
bordercolor="#666666"
|
||||
pagecolor="#ffffff" />
|
||||
<circle
|
||||
id="circle2"
|
||||
stroke-width="3"
|
||||
fill="none"
|
||||
stroke="inherit"
|
||||
r="12.5"
|
||||
cy="14"
|
||||
cx="14" />
|
||||
<!-- <path d="M0.5,13.5a12.5,12.5 0 1,0 25,0a12.5,12.5 0 1,0 -25,0" fill="none" stroke="inherit" /> -->
|
||||
<path
|
||||
id="path4"
|
||||
fill="inherit"
|
||||
d="m 12.0039,17.8164 v -0.8672 c 0,-0.5312 0.0742,-1 0.2227,-1.4062 0.1484,-0.4141 0.3867,-0.8047 0.7148,-1.1719 0.3281,-0.375 0.7617,-0.7578 1.3008,-1.1484 0.4844,-0.3438 0.8711,-0.6524 1.1601,-0.9258 0.2969,-0.2735 0.5118,-0.543 0.6446,-0.8086 0.1406,-0.2735 0.2109,-0.5821 0.2109,-0.9258 0,-0.50781 -0.1875,-0.89453 -0.5625,-1.16016 -0.375,-0.26562 -0.8984,-0.39843 -1.5703,-0.39843 -0.6719,0 -1.3398,0.10547 -2.0039,0.3164 -0.6563,0.21094 -1.3242,0.48828 -2.00391,0.83203 L 8.83984,7.58594 c 0.78125,-0.4375 1.62891,-0.78906 2.54296,-1.05469 0.9141,-0.27344 1.9141,-0.41016 3,-0.41016 1.6719,0 2.9649,0.40235 3.8789,1.20703 0.9219,0.79688 1.3828,1.8125 1.3828,3.04688 0,0.6562 -0.1054,1.2266 -0.3164,1.7109 -0.2031,0.4766 -0.5156,0.9219 -0.9375,1.336 -0.414,0.4062 -0.9336,0.8359 -1.5586,1.289 -0.4687,0.3438 -0.8281,0.6329 -1.0781,0.8672 -0.25,0.2344 -0.4219,0.4688 -0.5156,0.7031 -0.086,0.2266 -0.1289,0.504 -0.1289,0.8321 v 0.7031 z m -0.375,4.0078 c 0,-0.7344 0.1992,-1.25 0.5977,-1.5469 0.4062,-0.2968 0.8945,-0.4453 1.4648,-0.4453 0.5547,0 1.0313,0.1485 1.4297,0.4453 0.4062,0.2969 0.6094,0.8125 0.6094,1.5469 0,0.7031 -0.2032,1.211 -0.6094,1.5235 -0.3984,0.3125 -0.875,0.4687 -1.4297,0.4687 -0.5703,0 -1.0586,-0.1562 -1.4648,-0.4687 -0.3985,-0.3125 -0.5977,-0.8204 -0.5977,-1.5235 z" />
|
||||
</svg>
|
After Width: | Height: | Size: 3.1 KiB |
70
project-warstone/src/assets/icons/Help Icon.svg
Normal file
70
project-warstone/src/assets/icons/Help Icon.svg
Normal file
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
|
||||
sodipodi:docname="Help Icon.svg"
|
||||
id="svg6"
|
||||
version="1.1"
|
||||
viewBox="0 0 28 28"
|
||||
stroke="#ffffff"
|
||||
fill="#ffffff"
|
||||
height="28"
|
||||
width="28">
|
||||
<metadata
|
||||
id="metadata12">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs10" />
|
||||
<sodipodi:namedview
|
||||
inkscape:current-layer="svg6"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:window-y="1432"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:cy="14"
|
||||
inkscape:cx="14"
|
||||
inkscape:zoom="27.222222"
|
||||
fit-margin-bottom="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-top="0"
|
||||
showgrid="false"
|
||||
id="namedview8"
|
||||
inkscape:window-height="1369"
|
||||
inkscape:window-width="3440"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0"
|
||||
guidetolerance="10"
|
||||
gridtolerance="10"
|
||||
objecttolerance="10"
|
||||
borderopacity="1"
|
||||
bordercolor="#666666"
|
||||
pagecolor="#ffffff" />
|
||||
<circle
|
||||
id="circle2"
|
||||
stroke-width="3"
|
||||
fill="none"
|
||||
stroke="inherit"
|
||||
r="12.5"
|
||||
cy="14"
|
||||
cx="14" />
|
||||
<!-- <path d="M0.5,13.5a12.5,12.5 0 1,0 25,0a12.5,12.5 0 1,0 -25,0" fill="none" stroke="inherit" /> -->
|
||||
<path
|
||||
id="path4"
|
||||
fill="inherit"
|
||||
d="m 12.0039,17.8164 v -0.8672 c 0,-0.5312 0.0742,-1 0.2227,-1.4062 0.1484,-0.4141 0.3867,-0.8047 0.7148,-1.1719 0.3281,-0.375 0.7617,-0.7578 1.3008,-1.1484 0.4844,-0.3438 0.8711,-0.6524 1.1601,-0.9258 0.2969,-0.2735 0.5118,-0.543 0.6446,-0.8086 0.1406,-0.2735 0.2109,-0.5821 0.2109,-0.9258 0,-0.50781 -0.1875,-0.89453 -0.5625,-1.16016 -0.375,-0.26562 -0.8984,-0.39843 -1.5703,-0.39843 -0.6719,0 -1.3398,0.10547 -2.0039,0.3164 -0.6563,0.21094 -1.3242,0.48828 -2.00391,0.83203 L 8.83984,7.58594 c 0.78125,-0.4375 1.62891,-0.78906 2.54296,-1.05469 0.9141,-0.27344 1.9141,-0.41016 3,-0.41016 1.6719,0 2.9649,0.40235 3.8789,1.20703 0.9219,0.79688 1.3828,1.8125 1.3828,3.04688 0,0.6562 -0.1054,1.2266 -0.3164,1.7109 -0.2031,0.4766 -0.5156,0.9219 -0.9375,1.336 -0.414,0.4062 -0.9336,0.8359 -1.5586,1.289 -0.4687,0.3438 -0.8281,0.6329 -1.0781,0.8672 -0.25,0.2344 -0.4219,0.4688 -0.5156,0.7031 -0.086,0.2266 -0.1289,0.504 -0.1289,0.8321 v 0.7031 z m -0.375,4.0078 c 0,-0.7344 0.1992,-1.25 0.5977,-1.5469 0.4062,-0.2968 0.8945,-0.4453 1.4648,-0.4453 0.5547,0 1.0313,0.1485 1.4297,0.4453 0.4062,0.2969 0.6094,0.8125 0.6094,1.5469 0,0.7031 -0.2032,1.211 -0.6094,1.5235 -0.3984,0.3125 -0.875,0.4687 -1.4297,0.4687 -0.5703,0 -1.0586,-0.1562 -1.4648,-0.4687 -0.3985,-0.3125 -0.5977,-0.8204 -0.5977,-1.5235 z" />
|
||||
</svg>
|
After Width: | Height: | Size: 3.1 KiB |
19
project-warstone/src/components/Icon/index.tsx
Normal file
19
project-warstone/src/components/Icon/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { FC } from 'react'
|
||||
import { ReactComponent as help } from '../../assets/icons/Help Icon.svg';
|
||||
|
||||
const library = {
|
||||
help
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
className: string;
|
||||
icon: keyof typeof library;
|
||||
}
|
||||
|
||||
export const Icon: FC<IProps> = ({ className, icon }) => {
|
||||
const ICON = library[icon];
|
||||
|
||||
return (
|
||||
<ICON className={className} />
|
||||
)
|
||||
}
|
16
project-warstone/src/components/Poppables/help.tsx
Normal file
16
project-warstone/src/components/Poppables/help.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { FC, PropsWithChildren } from 'react'
|
||||
import { Poppable } from '../../lib/poppables/components/poppable'
|
||||
import { Icon } from '../Icon'
|
||||
|
||||
export const HelpPopper: FC<PropsWithChildren> = ({children}) => {
|
||||
|
||||
return (
|
||||
<Poppable
|
||||
content={children}
|
||||
preferredAlign="centered"
|
||||
preferredEdge="bottom"
|
||||
>
|
||||
<Icon icon="help" className="svg-white w-4 h-4" />
|
||||
</Poppable>
|
||||
)
|
||||
}
|
@@ -4,6 +4,7 @@ import { useObjectStateWrapper } from '../../hooks/useObjectState';
|
||||
import { FieldTypeInput } from './field-type-input';
|
||||
import { InputBinder } from '../../types/inputBinder';
|
||||
import { ValueField } from './value-field';
|
||||
import { HelpPopper } from '../Poppables/help';
|
||||
|
||||
interface IProps {
|
||||
update: (arg: FieldType) => void;
|
||||
@@ -21,33 +22,31 @@ export const FieldEditor: FC<IProps> = ({ update, field, fieldName }) => {
|
||||
}, [field])
|
||||
|
||||
return (
|
||||
<li className="odd:bg-black/50 flex gap-4 items-center p-2">
|
||||
<li className="odd:bg-black/50">
|
||||
<p>{fieldName}</p>
|
||||
<select className="capitalize" {...bindProperty('type')}>
|
||||
{fieldTypeOptions.map(o => (
|
||||
<option className="capitalize" value={FieldTypes[o]}>{o}</option>
|
||||
))}
|
||||
</select>
|
||||
{shouldShowValueField() && (
|
||||
// <>
|
||||
// {field.type === FieldTypes.dice && (
|
||||
// <label>
|
||||
// Sides:
|
||||
// <select {...bindProperty('value')}>
|
||||
// {diceSides.map(d => (
|
||||
// <option value={'d' + d}>{d}</option>
|
||||
// ))}
|
||||
// </select>
|
||||
// </label>
|
||||
// )}
|
||||
// {field.type === FieldTypes.type && (
|
||||
// <FieldTypeInput bind={bindProperty('value')} />
|
||||
|
||||
// )}
|
||||
// </>
|
||||
<ValueField type={field.type} bind={bindProperty('value')} />
|
||||
)}
|
||||
<label><input type="checkbox" {...bindPropertyCheck('isConstant')} /> Is constant</label>
|
||||
<div className=" flex gap-x-4 items-center p-2">
|
||||
<label className="w-min">
|
||||
Field Type:
|
||||
<select className="capitalize" {...bindProperty('type')}>
|
||||
{fieldTypeOptions.map(o => (
|
||||
<option className="capitalize" value={FieldTypes[o]}>{o}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{shouldShowValueField() && (
|
||||
<ValueField type={field.type} bind={bindProperty('value')} />
|
||||
)}
|
||||
<span className="flex items-center gap-2">
|
||||
<label><input type="checkbox" {...bindPropertyCheck('isConstant')} /> Is constant</label>
|
||||
<HelpPopper>
|
||||
<p className="text-sm">Constant values can't be overwritten in publications. When a dice field is set to a constant value, it instead rolls a dice of that value whenever this field is displayed (unless exported). This could be useful for a randomly generated scenario or for cards being drawn as the dice value will automatically be determined by the dice roll.</p>
|
||||
</HelpPopper>
|
||||
</span>
|
||||
<label className="w-min">
|
||||
Limit:
|
||||
<input className="w-12" type="number" {...bindProperty('limit')} />
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { FC, useCallback, useState } from 'react'
|
||||
import { FC, useCallback, useEffect, useState } from 'react'
|
||||
import AnimatedPageContainer from '../AnimatedPageContainer';
|
||||
import { TypeEditor } from './type-editor';
|
||||
import { useObjectState, useObjectStateWrapper } from '../../hooks/useObjectState';
|
||||
@@ -6,25 +6,58 @@ import { Schema, TypeType } from '../../types/schema';
|
||||
import { useInput } from '../../hooks/useInput';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { SchemaEditAtom } from '../../recoil/atoms/schema';
|
||||
import { GameSystemsService } from '../../services/game-systems';
|
||||
import { SchemaViewer } from './schema-viewer';
|
||||
|
||||
|
||||
export const SchemaBuilder: FC = () => {
|
||||
const [schema, setSchema] = useRecoilState(SchemaEditAtom);
|
||||
const {update: updateSchema} = useObjectStateWrapper<Schema>(schema, setSchema);
|
||||
const { update: updateSchema } = useObjectStateWrapper<Schema>(schema, setSchema);
|
||||
|
||||
const {value: typeName, bind: bindTypeName, reset: resetTypeName} = useInput('');
|
||||
const { value: typeName, bind: bindTypeName, reset: resetTypeName } = useInput('');
|
||||
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
const [pageNumber, setPageNumber] = useState(0);
|
||||
|
||||
const [lastSaved, setLastSaved] = useState(schema);
|
||||
|
||||
const fetchSchema = useCallback(async () => {
|
||||
const result = await GameSystemsService.getSchema('')
|
||||
if (result.status !== 200) return;
|
||||
const fetchedSchema = await result.json();
|
||||
// if (fetchedSchema.name === schema.name) return;
|
||||
setSchema(fetchedSchema);
|
||||
setLastSaved(fetchedSchema);
|
||||
}, [setSchema])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSchema();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const [selectedType, setSelectedType] = useState('');
|
||||
|
||||
const saveType = useCallback((name: string, type: TypeType) => {
|
||||
updateSchema(e => ({
|
||||
types: {
|
||||
...e.types,
|
||||
[name]: type
|
||||
}
|
||||
}));
|
||||
resetTypeName();
|
||||
setPageNumber(0);
|
||||
setSelectedType('');
|
||||
}, [resetTypeName, updateSchema]);
|
||||
|
||||
|
||||
const saveSchema = useCallback(() => {
|
||||
GameSystemsService.saveSchema(schema);
|
||||
setLastSaved(schema);
|
||||
}, [schema])
|
||||
|
||||
const selectTypeForEdit = useCallback((typeKey: string) => {
|
||||
setSelectedType(typeKey);
|
||||
setPageNumber(1);
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="container flex gap-4 p-8">
|
||||
<div className="panel w-2/3 h-full">
|
||||
@@ -34,11 +67,26 @@ export const SchemaBuilder: FC = () => {
|
||||
<input type="text" {...bindTypeName} />
|
||||
<button className="interactive" disabled={!typeName} onClick={() => setPageNumber(1)}>Configure</button>
|
||||
</div>
|
||||
<TypeEditor name={typeName} saveType={saveType} />
|
||||
<TypeEditor name={selectedType || typeName} saveType={saveType} type={selectedType ? schema.types[selectedType as keyof typeof schema.types] : undefined} />
|
||||
</AnimatedPageContainer>
|
||||
</div>
|
||||
<div className="panel w-1/3 whitespace-pre-wrap">
|
||||
{JSON.stringify(schema, null, 2)}
|
||||
<div className="panel basis-1/3">
|
||||
<SchemaViewer schema={schema} onTypeClick={selectTypeForEdit} />
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={saveSchema}
|
||||
disabled={lastSaved === schema}
|
||||
>
|
||||
Save Schema
|
||||
</button>
|
||||
<button
|
||||
className="bg-red-800"
|
||||
onClick={() => setSchema(lastSaved)}
|
||||
disabled={lastSaved === schema}
|
||||
>
|
||||
Discard Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@@ -0,0 +1,53 @@
|
||||
import { FC, useCallback } from 'react'
|
||||
import { FieldType, FieldTypes, Schema, TypeType, fieldTypesWithValues } from '../../types/schema'
|
||||
|
||||
interface IProps {
|
||||
schema: Schema;
|
||||
onTypeClick?: (arg: string, arg1: TypeType) => void;
|
||||
}
|
||||
|
||||
export const SchemaViewer: FC<IProps> = ({ schema, onTypeClick }) => {
|
||||
|
||||
const createValueLable = useCallback((field: FieldType) => {
|
||||
if (field.isConstant) {
|
||||
if (field.type === FieldTypes.dice) return 'Auto-rolled'
|
||||
return 'Constant value:'
|
||||
}
|
||||
switch (field.type) {
|
||||
case FieldTypes.type: return 'Type'
|
||||
case FieldTypes.dice: return 'Dice'
|
||||
default: return '';
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* <div className="whitespace-pre-wrap">{JSON.stringify(schema, null, 2)}</div> */}
|
||||
<div>
|
||||
<p className="font-bold text-lg">{schema.name}</p>
|
||||
<ul className="rounded-lg overflow-hidden">
|
||||
{Object.entries(schema.types).map(([typeKey, type]) => (
|
||||
<li
|
||||
key={'type viewer' + typeKey}
|
||||
onClick={() => onTypeClick && onTypeClick(typeKey, type)}
|
||||
data-clickable={!!onTypeClick}
|
||||
className="odd:bg-black/50 p-2 data-[clickable=true]:cursor-pointer"
|
||||
>
|
||||
<p className="mb-2 font-bold">{typeKey}</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Object.entries(type).map(([fieldKey, field]) => (
|
||||
<div key={'field viewer' + fieldKey} className="rounded-lg border border-olive-drab p-2">
|
||||
<p className="font-bold">{fieldKey}</p>
|
||||
<p className="font-thin capitalize text-xs">{field.type}</p>
|
||||
<p className="font-thin capitalize text-xs">Maximum entries: {field.limit === 0 ? 'unlimited ' : field.limit}</p>
|
||||
{(field.isConstant || fieldTypesWithValues.includes(field.type)) && <p className="font-thin capitalize text-xs">{createValueLable(field)} {field.value}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
import { FC, useCallback } from 'react'
|
||||
import { FC, useCallback, useEffect } from 'react'
|
||||
import { FCC } from '../../types'
|
||||
import { FieldType, FieldTypes, TypeType } from '../../types/schema'
|
||||
import { useObjectState } from '../../hooks/useObjectState';
|
||||
@@ -8,12 +8,13 @@ import { FieldEditor } from './field-editor';
|
||||
interface IProps {
|
||||
name: string;
|
||||
saveType: (arg0: string, arg1: TypeType) => void;
|
||||
type?: TypeType
|
||||
}
|
||||
|
||||
const constantProperties = ['metadata'];
|
||||
|
||||
export const TypeEditor: FCC<IProps> = ({ saveType, name }) => {
|
||||
const { update: updateType, reset: resetType, state: type } = useObjectState<TypeType>({});
|
||||
export const TypeEditor: FCC<IProps> = ({ saveType, name, type: passedType }) => {
|
||||
const { update: updateType, reset: resetType, state: type, setState: setType } = useObjectState<TypeType>({});
|
||||
|
||||
const { value: propertyName, setValue: setPropertyName, bind: bindPropertyName, reset: resetPropertyName } = useInput('');
|
||||
|
||||
@@ -31,22 +32,29 @@ export const TypeEditor: FCC<IProps> = ({ saveType, name }) => {
|
||||
limit: 1,
|
||||
}
|
||||
})
|
||||
}, [propertyName, updateType])
|
||||
}, [propertyName, updateType]);
|
||||
|
||||
const updateField = useCallback((k: keyof typeof type) => (field: FieldType) => {
|
||||
updateType({ [k]: field })
|
||||
}, [updateType])
|
||||
}, [updateType]);
|
||||
|
||||
useEffect(() => {
|
||||
passedType && setType(passedType);
|
||||
}, [passedType, setType]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="subheader">Creating type "{name}"</p>
|
||||
<p className="subheader">{passedType ? 'Editing' : 'Creating'} type "{name}"</p>
|
||||
<input type="text" {...bindPropertyName} />
|
||||
<button disabled={!propertyName} onClick={addField}>Add Field</button>
|
||||
<ul>
|
||||
<ul className="rounded-lg overflow-hidden">
|
||||
{Object.entries(type).filter(([k]) => !constantProperties.includes(k)).map(([key, value]) => (
|
||||
<FieldEditor field={value} update={updateField(key)} fieldName={key} />
|
||||
))}
|
||||
</ul>
|
||||
<div>
|
||||
<button onClick={save} disabled={!Object.keys(type).length}>Save Type</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@@ -1,37 +1,68 @@
|
||||
import { FC } from 'react';
|
||||
import { ChangeEvent, EventHandler, FC, useCallback, useEffect, useRef } from 'react';
|
||||
import { FieldTypes } from '../../types/schema';
|
||||
import { InputBinder } from '../../types/inputBinder';
|
||||
import { FieldTypeInput } from './field-type-input';
|
||||
import { useInput } from '../../hooks/useInput';
|
||||
|
||||
interface IValueProps {
|
||||
type: FieldTypes;
|
||||
bind: InputBinder;
|
||||
}
|
||||
|
||||
const diceSides = [3, 4, 6, 8, 10, 12, 20, 100];
|
||||
const DICE_SIDES = [3, 4, 6, 8, 10, 12, 20, 100];
|
||||
|
||||
export const ValueField: FC<IValueProps> = ({ type, bind }) => {
|
||||
const { value: diceCount, bind: bindDiceCount } = useInput(1);
|
||||
const { value: diceSides, bind: bindDiceSides } = useInput('');
|
||||
|
||||
const diceInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
export const ValueField: FC<IValueProps> = ({type, bind}) => {
|
||||
switch (type) {
|
||||
case FieldTypes.dice:
|
||||
case FieldTypes.dice: {
|
||||
const onChange = (handler: (arg: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => void) => (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
handler(e)
|
||||
setTimeout(() => {
|
||||
if (!diceInputRef.current) return;
|
||||
e.target = diceInputRef.current;
|
||||
bind.onChange(e);
|
||||
}, 0)
|
||||
}
|
||||
return (
|
||||
<label>
|
||||
Sides:
|
||||
<select {...bind}>
|
||||
{diceSides.map(d => (
|
||||
<option value={'d' + d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<>
|
||||
<label className="w-min">
|
||||
Count:
|
||||
<input className="w-12" type="number" {...bindDiceCount}
|
||||
onChange={onChange(bindDiceCount.onChange)}
|
||||
/>
|
||||
</label>
|
||||
<label className="w-min">
|
||||
Sides:
|
||||
<select {...bindDiceSides}
|
||||
onChange={onChange(bindDiceSides.onChange)}
|
||||
>
|
||||
<option value=""></option>
|
||||
{DICE_SIDES.map(d => (
|
||||
<option value={'d' + d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<input ref={diceInputRef} className="hidden" type="text" name={bind.name} value={diceCount + diceSides} readOnly />
|
||||
</>
|
||||
);
|
||||
}
|
||||
case FieldTypes.type:
|
||||
return (
|
||||
<FieldTypeInput bind={bind} />
|
||||
)
|
||||
case FieldTypes.number:
|
||||
return (
|
||||
<input type="number" {...bind} />
|
||||
<label className="w-min">Value:<input className="w-16" type="number" {...bind} /></label>
|
||||
)
|
||||
case FieldTypes.text:
|
||||
return (
|
||||
<label className="w-min">Value:<input type="number" {...bind} /></label>
|
||||
)
|
||||
default:
|
||||
return <></>;
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
|
12
project-warstone/src/hooks/useDebounce.ts
Normal file
12
project-warstone/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useDebounce = (value: any, delay: number) => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
};
|
@@ -1,3 +1,5 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Chakra+Petch:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&family=Open+Sans:ital,wght@0,300;0,400;0,500;0,700;0,800;1,300;1,400;1,500;1,600;1,700;1,800&family=Orbitron:wght@400;500;600;700;800;900&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -6,8 +8,14 @@
|
||||
body {
|
||||
@apply bg-bastille text-white
|
||||
}
|
||||
input, select {
|
||||
@apply p-1 rounded-lg text-cinder-500
|
||||
|
||||
input,
|
||||
select {
|
||||
@apply p-1 rounded-lg text-cinder-500 interactive
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,25 +23,47 @@
|
||||
.panel {
|
||||
@apply bg-cinder shadow-xl p-8 rounded-xl
|
||||
}
|
||||
|
||||
.header {
|
||||
@apply text-2xl font-bold
|
||||
}
|
||||
|
||||
.subheader {
|
||||
@apply text-xl font-bold
|
||||
}
|
||||
|
||||
button {
|
||||
@apply interactive bg-olive-drab p-1
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.interactive {
|
||||
@apply border-2 rounded-lg border-falcon cursor-pointer
|
||||
}
|
||||
|
||||
.interactive svg {
|
||||
@apply fill-falcon
|
||||
}
|
||||
|
||||
.interactive:disabled {
|
||||
@apply border-falcon-300 brightness-50 cursor-default
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fade 300ms forwards ease-in;
|
||||
animation-delay: 300ms;
|
||||
}
|
||||
.fade-out {
|
||||
animation: fade 300ms forwards ease-in reverse;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {}
|
||||
|
||||
@keyframes fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,117 @@
|
||||
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react';
|
||||
import { FCC } from '../../../types';
|
||||
import { bulkRound } from '../../utils/bulkRound';
|
||||
import { clamp } from '../../utils/clamp';
|
||||
import { Portal } from '../../portal/components';
|
||||
|
||||
type edge = 'top' | 'bottom' | 'left' | 'right';
|
||||
type alignment = edge | 'centered';
|
||||
|
||||
interface IProps {
|
||||
preferredEdge: edge;
|
||||
preferredAlign: alignment;
|
||||
relativeElement: HTMLElement;
|
||||
spacing?: number;
|
||||
isClosing: boolean;
|
||||
isClosed: boolean;
|
||||
setHover: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
type position = { top: number, left: number, width?: number };
|
||||
|
||||
export const PoppableContent: FCC<IProps> = ({ preferredAlign, preferredEdge, children, relativeElement, spacing = 10, setHover, isClosing, isClosed }) => {
|
||||
const [popRef, setPopRef] = useState<HTMLDivElement>();
|
||||
const updateRef = useCallback((node: HTMLDivElement) => {
|
||||
if (!node) return;
|
||||
setPopRef(node);
|
||||
}, [])
|
||||
|
||||
const getAlignment = useCallback((relX: number, relY: number, relWidth: number, relHeight: number, popWidth: number, popHeight: number, edge: edge, align: alignment): position => {
|
||||
const pos = {
|
||||
top: relY,
|
||||
left: relX,
|
||||
}
|
||||
|
||||
switch (align) {
|
||||
case 'centered':
|
||||
pos.top = relY + (relHeight / 2) - (popHeight / 2);
|
||||
pos.left = relX + (relWidth / 2) - (popWidth / 2);
|
||||
break;
|
||||
case 'top':
|
||||
pos.top = relY;
|
||||
break;
|
||||
case 'bottom':
|
||||
pos.top = relY + relHeight - popHeight;
|
||||
break;
|
||||
case 'left':
|
||||
pos.left = relX;
|
||||
break;
|
||||
case 'right':
|
||||
pos.left = relX + relWidth - popWidth;
|
||||
break;
|
||||
}
|
||||
|
||||
return pos;
|
||||
}, [])
|
||||
|
||||
const getPosition = useCallback((popWidth: number, popHeight: number, edge: edge, align: alignment) => {
|
||||
const rel = relativeElement.getBoundingClientRect();
|
||||
const [relX, relY, relWidth, relHeight] = bulkRound(rel.x, rel.y, rel.width, rel.height);
|
||||
|
||||
const pos: position = { top: 100, left: 100 };
|
||||
const alignment = getAlignment(relX, relY, relWidth, relHeight, popWidth, popHeight, edge, align);
|
||||
|
||||
switch (edge) {
|
||||
case 'top':
|
||||
pos.top = relY - popHeight - spacing + document.documentElement.scrollTop;
|
||||
pos.left = alignment.left;
|
||||
break;
|
||||
case 'bottom':
|
||||
pos.top = relY + relHeight + spacing + document.documentElement.scrollTop;
|
||||
pos.left = alignment.left;
|
||||
break;
|
||||
case 'left':
|
||||
pos.left = relX - popWidth - spacing;
|
||||
pos.top = alignment.top + document.documentElement.scrollTop;
|
||||
break;
|
||||
case 'right':
|
||||
pos.left = relX + relWidth + spacing;
|
||||
pos.top = alignment.top + document.documentElement.scrollTop;
|
||||
break;
|
||||
}
|
||||
|
||||
return pos;
|
||||
}, [getAlignment, relativeElement, spacing])
|
||||
|
||||
const getClampedPosition = useCallback(() => {
|
||||
if (!popRef) return { opacity: 0 }
|
||||
|
||||
const pop = popRef.getBoundingClientRect();
|
||||
const [popWidth, popHeight] = bulkRound(pop.width, pop.height);
|
||||
|
||||
const pos = getPosition(popWidth, popHeight, preferredEdge, preferredAlign);
|
||||
const { innerHeight, innerWidth } = window;
|
||||
|
||||
pos.top = ['left', 'right'].includes(preferredEdge) ? clamp(pos.top, spacing, innerHeight - popHeight - spacing) : pos.top;
|
||||
pos.left = ['top', 'bottom'].includes(preferredEdge) ? clamp(pos.left, spacing, innerWidth - popWidth - spacing): pos.left;
|
||||
|
||||
return pos;
|
||||
}, [popRef, getPosition, preferredEdge, preferredAlign, spacing])
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div
|
||||
ref={updateRef}
|
||||
style={getClampedPosition()}
|
||||
data-fading={isClosing}
|
||||
data-visible={!isClosing}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
// className="absolute w-[400px] border"
|
||||
className="bg-cinder border-2 border-falcon p-2 rounded-lg absolute transition-opacity data-[visible=true]:z-10 data-[visible=true]:opacity-100 data-[visible=false]:opacity-0 -z-10 max-w-[400px]"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Portal>
|
||||
)
|
||||
}
|
47
project-warstone/src/lib/poppables/components/poppable.tsx
Normal file
47
project-warstone/src/lib/poppables/components/poppable.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { FC, ReactNode, useCallback, useEffect, useState } from 'react'
|
||||
import { PoppableContent } from './poppable-content';
|
||||
import { FCC } from '../../../types';
|
||||
import { useDebounce } from '../../../hooks/useDebounce';
|
||||
|
||||
interface IProps {
|
||||
content: ReactNode;
|
||||
className?: string;
|
||||
preferredEdge: 'top' | 'bottom' | 'left' | 'right';
|
||||
preferredAlign: 'centered' | 'top' | 'bottom' | 'left' | 'right';
|
||||
spacing?: number;
|
||||
}
|
||||
|
||||
export const Poppable: FCC<IProps> = ({ className, content, children, preferredEdge, preferredAlign, spacing }) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const closing = useDebounce(!isHovered, 1000);
|
||||
const closed = useDebounce(closing, 300);
|
||||
|
||||
const [ref, setRef] = useState<HTMLElement>();
|
||||
|
||||
const updateRef = useCallback((node: HTMLElement) => {
|
||||
if (!node) return;
|
||||
setRef(node)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={updateRef}
|
||||
className={className}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{!!ref && <PoppableContent
|
||||
preferredAlign={preferredAlign}
|
||||
preferredEdge={preferredEdge}
|
||||
spacing={spacing}
|
||||
isClosing={closing}
|
||||
isClosed={closed}
|
||||
relativeElement={ref}
|
||||
setHover={setIsHovered}
|
||||
>{content}</PoppableContent>}
|
||||
</>
|
||||
)
|
||||
}
|
26
project-warstone/src/lib/portal/components/index.ts
Normal file
26
project-warstone/src/lib/portal/components/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FCC } from '../../../types';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface IProps {
|
||||
className?: string;
|
||||
el?: string;
|
||||
}
|
||||
|
||||
export const Portal: FCC<IProps> = ({ children, className = 'root-portal', el = 'div' }) => {
|
||||
const [container] = useState(() => {
|
||||
// This will be executed only on the initial render
|
||||
// https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
|
||||
return document.createElement(el);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
container.classList.add(className);
|
||||
document.body.appendChild(container);
|
||||
return () => {
|
||||
document.body.removeChild(container);
|
||||
}
|
||||
}, [className, container]);
|
||||
|
||||
return createPortal(children, container);
|
||||
}
|
1
project-warstone/src/lib/utils/bulkRound.ts
Normal file
1
project-warstone/src/lib/utils/bulkRound.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const bulkRound = (...args: number[]): number[] => args.map(n => Math.round(n));
|
2
project-warstone/src/lib/utils/clamp.ts
Normal file
2
project-warstone/src/lib/utils/clamp.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const clamp = (value: number, min: number, max: number) =>
|
||||
Math.max(Math.min(value, max), min)
|
@@ -1,5 +1,5 @@
|
||||
import { atom } from 'recoil';
|
||||
import { Schema, TypeType } from '../../types/schema';
|
||||
import { Schema } from '../../types/schema';
|
||||
|
||||
export const SchemaEditAtom = atom<Schema>({
|
||||
key: 'schema-edit',
|
||||
|
@@ -8,7 +8,7 @@ export const GameSystemsService = {
|
||||
return { status: 200 }
|
||||
},
|
||||
// todo - connect to service to fetch schema for game
|
||||
getSchema: async (id: string) => {
|
||||
getSchema: async (id: string): Promise<{status: 200 | 404, json: () => Promise<Schema>}> => {
|
||||
const schema = localStorage.getItem('schema');
|
||||
|
||||
if (schema)
|
||||
@@ -18,7 +18,8 @@ export const GameSystemsService = {
|
||||
}
|
||||
|
||||
return {
|
||||
status: 404
|
||||
status: 404,
|
||||
json: async () => ({name:'', types: {}})
|
||||
}
|
||||
}
|
||||
}
|
7
project-warstone/src/svg.d.ts
vendored
Normal file
7
project-warstone/src/svg.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
declare module "*.svg" {
|
||||
import * as React from "react";
|
||||
|
||||
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
@@ -19,10 +19,11 @@ export type Schema = {
|
||||
export enum FieldTypes {
|
||||
number = 'number',
|
||||
text = 'text',
|
||||
'long text' = 'long text',
|
||||
checkbox = 'checkbox',
|
||||
type = '@type',
|
||||
dice = 'dice'
|
||||
}
|
||||
|
||||
export const fieldTypeOptions: (keyof typeof FieldTypes)[] = ['number', 'text', 'checkbox', 'type', 'dice']
|
||||
export const fieldTypeOptions: (keyof typeof FieldTypes)[] = ['number', 'text', 'long text', 'checkbox', 'type', 'dice']
|
||||
export const fieldTypesWithValues = [FieldTypes.dice, FieldTypes.type];
|
||||
|
Reference in New Issue
Block a user