Type editor and schema viewer. Poppables and help icon

This commit is contained in:
Emma
2023-06-11 10:25:32 -06:00
parent 42c0004150
commit b951d1970d
36 changed files with 1190 additions and 77 deletions

View File

@@ -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>
)

View 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

View 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

View 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

View 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} />
)
}

View 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>
)
}

View File

@@ -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:&nbsp;
// <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:&nbsp;
<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>
)
}

View File

@@ -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>
)

View File

@@ -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>
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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:&nbsp;
<select {...bind}>
{diceSides.map(d => (
<option value={'d' + d}>{d}</option>
))}
</select>
</label>
<>
<label className="w-min">
Count:&nbsp;
<input className="w-12" type="number" {...bindDiceCount}
onChange={onChange(bindDiceCount.onChange)}
/>
</label>
<label className="w-min">
Sides:&nbsp;
<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 <></>;
}
}

View 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;
};

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View 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>}
</>
)
}

View 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);
}

View File

@@ -0,0 +1 @@
export const bulkRound = (...args: number[]): number[] => args.map(n => Math.round(n));

View File

@@ -0,0 +1,2 @@
export const clamp = (value: number, min: number, max: number) =>
Math.max(Math.min(value, max), min)

View File

@@ -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',

View File

@@ -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
View 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;
}

View File

@@ -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];