New icons, better page transition, truncated popper, adds disallowSpaces option to binders, delete and edit buttons

This commit is contained in:
Emma 2023-06-14 03:09:27 -06:00
parent e78e4304a4
commit f84ef2ee19
21 changed files with 277 additions and 60 deletions

View File

@ -7,8 +7,8 @@ function App() {
return (
<RecoilRoot>
<RecoilizeDebugger />
{/* <SchemaBuilder /> */}
<GameSystemEditor />
<SchemaBuilder />
{/* <GameSystemEditor /> */}
</RecoilRoot>
)
}

View File

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect class="anvil-base" x="4.5" y="9.5" width="10" height="2" rx="0.5" stroke="inherit" fill="none" />
<path class="anvil-body"
d="M6 1H2C3 2 5.5 2.5 7 3C8.5 3.5 8.54315 4.2918 7 6L6 7V8H13V7C13 7 11.5 6 11 5C10.5 4 11 2.5 15 2V0.5H6V1Z"
stroke="inherit" fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,8 @@
<svg width="35" height="35" viewBox="0 0 23 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12H21V32C21 33.1046 20.1046 34 19 34H5C3.89543 34 3 33.1046 3 32V12Z" stroke="inherit" fill="none" stroke-width="2"/>
<path d="M7 16L7 29" stroke="inherit" fill="none" stroke-width="2" stroke-linecap="round"/>
<path d="M12 16V29" stroke="inherit" fill="none" stroke-width="2" stroke-linecap="round"/>
<path d="M17 16V29" stroke="inherit" fill="none" stroke-width="2" stroke-linecap="round"/>
<path d="M8.59244 1.36064L12.5317 0.666048C12.8036 0.618097 13.063 0.799681 13.1109 1.07163L13.3714 2.54884L8.44734 3.41708L8.18687 1.93987C8.13891 1.66792 8.3205 1.40859 8.59244 1.36064Z" stroke="inherit" fill="none"/>
<path d="M2.05644 4.54394L19.783 1.41827C20.5988 1.27442 21.3768 1.81917 21.5207 2.63501L21.9548 5.09703L1.27382 8.74365L0.8397 6.28163C0.695845 5.46578 1.2406 4.6878 2.05644 4.54394Z" stroke="inherit" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 943 B

View File

@ -0,0 +1,12 @@
<svg class="trash-can" width="35" height="30" viewBox="0 0 22 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- body -->
<path d="M2 7H20V27C20 28.1046 19.1046 29 18 29H4C2.89543 29 2 28.1046 2 27V7Z" stroke="inherit" fill="none" stroke-width="2"/>
<!-- body lines -->
<path d="M6 11L6 24" stroke="inherit" fill="none" stroke-width="2" stroke-linecap="round"/>
<path d="M11 11V24" stroke="inherit" fill="none" stroke-width="2" stroke-linecap="round"/>
<path d="M16 11V24" stroke="inherit" fill="none" stroke-width="2" stroke-linecap="round"/>
<!-- handle -->
<path class="trash-lid" d="M9 0.5H13C13.2761 0.5 13.5 0.723858 13.5 1V2.5H8.5V1C8.5 0.723858 8.72386 0.5 9 0.5Z" stroke="inherit" fill="none"/>
<!-- cap -->
<path class="trash-lid" d="M2 2.5H20C20.8284 2.5 21.5 3.17157 21.5 4V6.5H0.5V4C0.5 3.17157 1.17157 2.5 2 2.5Z" stroke="inherit" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 872 B

View File

@ -0,0 +1,6 @@
.page {
transition:
transform 500ms,
opacity 300ms,
z-index 0ms 500ms
}

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, ReactNode, useRef, useLayoutEffect, useCallback } from 'react';
import { FCC } from '../../types';
import './index.css';
interface IProps {
currentPage: number;
@ -10,12 +11,24 @@ const AnimatedPageContainer: FCC<IProps> = ({ children, currentPage }) => {
const renderChild = (child: ReactNode, index: number) => {
const isActive = index === currentPage;
let position = 'active';
switch ((index - currentPage) / Math.abs(index - currentPage)) {
case 1:
position = 'right';
break;
case -1:
position = 'left';
break;
default:
position = 'active';
}
return (
<div
key={`page container ${uuid}: ${index}`}
data-active={isActive}
className="data-[active=true]:opacity-100 data-[active=true]:static opacity-0 top-0 left-0 absolute transition-opacity duration-300 data-[active=false]:-z-10"
data-position={position}
className="data-[active=true]:opacity-100 data-[active=true]:static opacity-0 top-0 left-0 absolute page data-[position=left]:-translate-x-96 data-[position=right]:translate-x-96 translate-x-0"
>
{child}
</div>
@ -23,7 +36,7 @@ const AnimatedPageContainer: FCC<IProps> = ({ children, currentPage }) => {
};
return (
<div className="relative">
<div className="relative overflow-hidden">
{React.Children.map(children, renderChild)}
</div>
);

View File

@ -1,8 +1,14 @@
import { FC } from 'react'
import { ReactComponent as help } from '../../assets/icons/Help Icon.svg';
import { ReactComponent as trash } from '../../assets/icons/Trash Icon.svg';
import { ReactComponent as trash_hover } from '../../assets/icons/Trash Icon Open.svg';
import { ReactComponent as anvil } from '../../assets/icons/Anvil Icon.svg';
const library = {
help
help,
trash,
trash_hover,
anvil
}
interface IProps {

View File

@ -0,0 +1,15 @@
import { FC, PropsWithChildren } from 'react';
import { Poppable } from '../../lib/poppables/components/poppable';
export const Truncate: FC<PropsWithChildren> = ({children}) => {
return (
<Poppable
content={children}
preferredAlign="centered"
preferredEdge="top"
>
<p className="truncate max-w-full underline">{children}</p>
</Poppable>
);
}

View File

@ -0,0 +1,19 @@
import {FC} from 'react'
import { Publication } from '../../types/publication';
import { useObjectState } from '../../hooks/useObjectState';
export const PublicationEditor: FC = () => {
const {value: publication, setValue: setPublication, bindProperty: bindPublication, update: updatePublication, reset: resetPublication} = useObjectState<Publication>({
gameSystemId: '',
name: '',
status: 'private',
templates: {},
version: '1'
});
return (
<div className="container p-8">
</div>
)
}

View File

@ -9,6 +9,7 @@ import { SchemaEditAtom } from '../../recoil/atoms/schema';
import { GameSystemsService } from '../../services/game-systems';
import { SchemaViewer } from './schema-viewer';
import { TemplateEditor } from './template-editor';
import { Icon } from '../Icon';
export const SchemaBuilder: FC = () => {
@ -60,7 +61,7 @@ export const SchemaBuilder: FC = () => {
setPageNumber(1);
}, [])
const { value: templateName, bind: bindTemplateName, reset: resetTemplateName } = useInput('');
const { value: templateName, bind: bindTemplateName, reset: resetTemplateName } = useInput('', { disallowSpaces: true });
const addTemplate = useCallback(() => {
updateSchema(s => ({
templates: {
@ -83,25 +84,55 @@ export const SchemaBuilder: FC = () => {
}))
}, [updateSchema])
const deleteType = useCallback((key: string) => {
updateSchema(s => {
const types = { ...s.types };
delete types[key];
return { types }
})
}, [updateSchema])
return (
<div className="container flex gap-4 p-8">
<div className="panel w-2/3 h-full">
<p className="subheader">Add a template</p>
<input type="text" {...bindTemplateName} />
<button onClick={addTemplate} disabled={!templateName}>Add</button>
<ul>
{Object.entries(schema.templates).map(([templateKey, template]) => (
<TemplateEditor templateKey={templateKey} template={template} update={updateTemplate} />
))}
</ul>
<AnimatedPageContainer currentPage={pageNumber}>
<div>
<p className="subheader">Add a type</p>
<input type="text" {...bindTypeName} />
<button className="interactive" disabled={!typeName} onClick={() => setPageNumber(1)}>Configure</button>
<div className="panel w-2/3 h-full flex flex-col gap-4">
<div>
<p className="subheader mb-2">Add a template</p>
<div className="mb-2">
<input type="text" {...bindTemplateName} />
<button onClick={addTemplate} disabled={!templateName}>Add</button>
</div>
<TypeEditor name={selectedType || typeName} saveType={saveType} type={selectedType ? schema.types[selectedType as keyof typeof schema.types] : undefined} />
</AnimatedPageContainer>
<ul className="rounded-lg overflow-hidden">
{Object.entries(schema.templates).map(([templateKey, template]) => (
<TemplateEditor templateKey={templateKey} template={template} update={updateTemplate} />
))}
</ul>
</div>
<hr />
<div>
<AnimatedPageContainer currentPage={pageNumber}>
<div>
<p className="subheader mb-2">Add a type</p>
<input type="text" {...bindTypeName} />
<button className="interactive" disabled={!typeName} onClick={() => setPageNumber(1)}>Configure</button>
</div>
<TypeEditor name={selectedType || typeName} saveType={saveType} type={selectedType ? schema.types[selectedType as keyof typeof schema.types] : undefined} />
</AnimatedPageContainer>
<ul className="mt-3 w-96">
{Object.keys(schema.types).map(t => (
<li className="odd:bg-black/50 flex justify-between p-2">
{t}
<div className="flex gap-3">
<button title="Edit" className="no-default" onClick={() => selectTypeForEdit(t)}>
<Icon icon="anvil" className="anvil svg-olive-drab hover:svg-olive-drab-100 w-6 h-6" />
</button>
<button title="Delete" className="no-default" onClick={() => deleteType(t)}>
<Icon icon="trash" className="trash-can svg-red-700 hover:svg-red-500 w-6 h-6" />
</button>
</div>
</li>
))}
</ul>
</div>
</div>
<div className="panel basis-1/3">
<SchemaViewer schema={schema} onTypeClick={selectTypeForEdit} />

View File

@ -1,5 +1,6 @@
import { FC, useCallback } from 'react'
import { FieldType, FieldTypes, Schema, TypeType, fieldTypesWithValues } from '../../types/schema'
import { Truncate } from '../Poppables/truncation';
interface IProps {
schema: Schema;
@ -16,6 +17,7 @@ export const SchemaViewer: FC<IProps> = ({ schema, onTypeClick }) => {
switch (field.type) {
case FieldTypes.type: return 'Type:'
case FieldTypes.dice: return 'Dice:'
case FieldTypes.select: return 'Options:'
default: return '';
}
}, [])
@ -53,7 +55,7 @@ export const SchemaViewer: FC<IProps> = ({ schema, onTypeClick }) => {
<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>}
{(field.isConstant || fieldTypesWithValues.includes(field.type)) && <p className="font-thin capitalize text-xs">{createValueLable(field)} <Truncate>{field.value}</Truncate></p>}
</div>
))}
</div>

View File

@ -1,9 +1,10 @@
import { FC, useCallback } from 'react'
import { FC, useCallback, useState } from 'react'
import { Template } from '../../types/schema';
import { useObjectStateWrapper } from '../../hooks/useObjectState';
import { TEMPLATE_TYPES } from '../../constants/TemplateTypes';
import { SchemaEditAtom } from '../../recoil/atoms/schema';
import { useRecoilValue } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Icon } from '../Icon';
interface IProps {
templateKey: string;
@ -12,33 +13,51 @@ interface IProps {
}
export const TemplateEditor: FC<IProps> = ({ templateKey, update, template }) => {
const schema = useRecoilValue(SchemaEditAtom);
const [schema, setSchema] = useRecoilState(SchemaEditAtom);
const updateTemplate = useCallback((t: Template | ((arg: Template) => Template)) => {
update(templateKey, typeof t === 'function' ? t(template) : t)
}, [templateKey, update, template])
const { bindProperty, bindPropertyCheck } = useObjectStateWrapper(template, updateTemplate)
const deleteTemplate = useCallback(() => {
setSchema(s => {
const templates = { ...s.templates };
delete templates[templateKey]
return {
...s,
templates
}
})
}, [setSchema, templateKey])
return (
<li>
<li className="odd:bg-black/50 p-2">
<p className="font-bold">{templateKey}</p>
<div className="flex gap-4">
<label className="w-min">
Type
<input type="text" {...bindProperty('type')} list="type-editor-type-list" />
<datalist id="type-editor-type-list">
{Object.keys(TEMPLATE_TYPES).map(k => (
<option className="capitalize" value={k}>{k}</option>
))}
{Object.keys(schema.types).map(k => (
<option className="capitalize" value={k}>{k}</option>
))}
</datalist>
</label>
<label>
<input type="checkbox" {...bindPropertyCheck('publishable')} />
Can create publications
</label>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 pl-2">
<label className="w-min">
Type:
<input type="text" {...bindProperty('type', { disallowSpaces: true })} list="type-editor-type-list" />
<datalist id="type-editor-type-list">
{Object.keys(TEMPLATE_TYPES).map(k => (
<option value={k}>{k}</option>
))}
{Object.keys(schema.types).map(k => (
<option value={k}>{k}</option>
))}
</datalist>
</label>
<label>
<input type="checkbox" {...bindPropertyCheck('publishable')} />
&nbsp;Can create publications
</label>
</div>
<button
className="no-default"
onClick={deleteTemplate}
>
<Icon icon="trash" className="svg-red-700 hover:svg-red-500 trash-can w-6 h-6" />
</button>
</div>
</li>
)

View File

@ -1,4 +1,4 @@
import { FC, useCallback, useEffect } from 'react'
import { useCallback, useEffect } from 'react'
import { FCC } from '../../types'
import { FieldType, FieldTypes, TypeType } from '../../types/schema'
import { useObjectState } from '../../hooks/useObjectState';
@ -16,7 +16,7 @@ const constantProperties = ['metadata'];
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('');
const { value: propertyName, bind: bindPropertyName, reset: resetPropertyName } = useInput('', { disallowSpaces: true });
const save = () => {
saveType(name, type);

View File

@ -3,6 +3,7 @@ import { FieldTypes } from '../../types/schema';
import { InputBinder } from '../../types/inputBinder';
import { FieldTypeInput } from './field-type-input';
import { useInput } from '../../hooks/useInput';
import { HelpPopper } from '../Poppables/help';
interface IValueProps {
type: FieldTypes;
@ -62,6 +63,38 @@ export const ValueField: FC<IValueProps> = ({ type, bind }) => {
return (
<label className="w-min">Value:<input type="number" {...bind} /></label>
)
case FieldTypes.select:
return (
<>
<label className="w-min">
<div className="flex gap-2 items-center">
Values:
<HelpPopper>
<p className="text-xs">
A comma separated list (no spaces, spaces are reserved for values) of options that can be chosen while creating publications. Ex: earthquake,wind storm,fire tornado,rainbow. Alternatively, you can specify a display value and an actual value separated with a colon. This is useful for when you want to create a reference in a publication with a dropdown field. Ex: Rapid Fire:^core.weaponAbilities[name=rapid fire],Heavy:^core.weaponAbilities[name=heavy]
</p>
</HelpPopper>
</div>
<input type="text" {...bind} />
</label>
</>
)
case FieldTypes.any:
return (
<>
<label className="w-min">
<div className="flex gap-2 items-center">
Type options:
<HelpPopper>
<p className="text-xs">
A comma separated list (no spaces, spaces are reserved for values) of options that are names of types that can be selected when creating a publication, Ex: dice,number,text. Do not leave this blank, allowing for any type to be selected makes querying gross.
</p>
</HelpPopper>
</div>
<input type="text" {...bind} />
</label>
</>
)
default:
return <></>;
}

View File

@ -45,7 +45,7 @@ export const TEMPLATE_TYPES: Record<string, TypeType> = {
value: ''
}
},
tableRow: {
table_row: {
columns: {
isConstant: false,
limit: 0,

View File

@ -1,6 +1,11 @@
import { useState, ChangeEvent } from 'react';
export const useInput = <T extends string | number>(initialValue: T) => {
type InputHookConfig = {
disallowSpaces?: boolean;
spaceReplacer?: string;
}
export const useInput = <T extends string | number>(initialValue: T, config?: InputHookConfig) => {
const [value, setValue] = useState<T>(initialValue);
return {
@ -10,11 +15,11 @@ export const useInput = <T extends string | number>(initialValue: T) => {
bind: {
value: value,
onChange: (event: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const changed: string | number = typeof initialValue === 'number' ? parseInt(event.target.value) : event.target.value;
setValue(changed as T);
}
const changed: string | number = typeof initialValue === 'number' ? parseInt(event.target.value) : config?.disallowSpaces ? event.target.value.replace(' ', config.spaceReplacer || '_') : event.target.value;
setValue(changed as T);
}
};
}
};
};
export const useCheckbox = (initial: boolean) => {

View File

@ -1,10 +1,15 @@
import React, { ChangeEvent, useCallback, useState } from 'react';
import { InputBinder } from '../types/inputBinder';
type ObjectStateHookConfig = {
disallowSpaces?: boolean;
spaceReplacer?: string;
}
export const useObjectState = <T extends object>(initial: T) => {
const [state, setState] = useState<T>(initial || {} as T);
const bindProperty = useCallback(<K extends keyof T>(property: K) => ({
const bindProperty = useCallback(<K extends keyof T>(property: K, config: ObjectStateHookConfig) => ({
value: state[property] ?? '',
name: property,
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
@ -14,7 +19,10 @@ export const useObjectState = <T extends object>(initial: T) => {
[event.target.name]: (
(typeof value[property] === 'number') ?
Number(event.target.value) || 0 :
event.target.value)
config?.disallowSpaces ?
event.target.value.replace(' ', config.spaceReplacer || '_') :
event.target.value
)
}
))
}), [state])
@ -46,7 +54,7 @@ export const useObjectState = <T extends object>(initial: T) => {
export const useObjectStateWrapper = <T extends object>(state: T, setState: React.Dispatch<React.SetStateAction<T>>) => {
const bindProperty = useCallback(<K extends keyof T>(property: K): InputBinder => ({
const bindProperty = useCallback(<K extends keyof T>(property: K, config?: ObjectStateHookConfig): InputBinder => ({
value: state[property] ?? '',
name: property.toString(),
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
@ -56,7 +64,10 @@ export const useObjectStateWrapper = <T extends object>(state: T, setState: Reac
[event.target.name]: (
(typeof value[property] === 'number') ?
Number(event.target.value) || 0 :
event.target.value)
config?.disallowSpaces ?
event.target.value.replace(' ', config.spaceReplacer || '_') :
event.target.value
)
}
))
}), [setState, state])

View File

@ -41,7 +41,7 @@
@apply text-xl font-bold
}
button {
button:not(.no-default) {
@apply interactive bg-olive-drab p-1
}
@ -65,6 +65,26 @@
.fade-out {
animation: fade 300ms forwards ease-in reverse;
}
.trash-can, .anvil {
overflow: visible;
}
.trash-can path.trash-lid, .anvil .anvil-base, .anvil .anvil-body {
transition: 300ms transform, 300ms rotate, 300ms fill, 300ms stroke;
}
.trash-can:hover path.trash-lid {
transform: translate(-5%, -10%);
rotate: -10deg;
}
.anvil:hover .anvil-base {
transform: translate(0px, 5%);
}
.anvil:hover .anvil-body {
transform: translate(0px, -5%);
}
}
@layer utilities {}

View File

@ -0,0 +1 @@
export type WithId<T> = {id: string} & T;

View File

@ -0,0 +1,9 @@
import { WithId } from './gemerics';
export type Publication = {
name: string;
version: string;
gameSystemId: string;
status: 'published' | 'private';
templates: Record<string, WithId<any>[]>;
}

View File

@ -30,8 +30,9 @@ export enum FieldTypes {
checkbox = 'checkbox',
type = '@type',
dice = 'dice',
any = '@select'
any = '@select',
select = 'select'
}
export const fieldTypeOptions: (keyof typeof FieldTypes)[] = ['number', 'text', 'long text', 'checkbox', 'type', 'dice']
export const fieldTypesWithValues = [FieldTypes.dice, FieldTypes.type];
export const fieldTypeOptions: (keyof typeof FieldTypes)[] = ['number', 'text', 'long text', 'checkbox', 'type', 'dice', 'select', 'any']
export const fieldTypesWithValues = [FieldTypes.dice, FieldTypes.type, FieldTypes.select, FieldTypes.any];