Compare commits

...

6 Commits

34 changed files with 936 additions and 260 deletions

22
.vscode/settings.json vendored
View File

@@ -1,26 +1,6 @@
{
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#83a0ea",
"activityBar.background": "#83a0ea",
"activityBar.foreground": "#15202b",
"activityBar.inactiveForeground": "#15202b99",
"activityBarBadge.background": "#fae4ea",
"activityBarBadge.foreground": "#15202b",
"commandCenter.border": "#e7e7e799",
"sash.hoverBorder": "#83a0ea",
"statusBar.background": "#577ee3",
"statusBar.foreground": "#e7e7e7",
"statusBarItem.hoverBackground": "#83a0ea",
"statusBarItem.remoteBackground": "#577ee3",
"statusBarItem.remoteForeground": "#e7e7e7",
"titleBar.activeBackground": "#577ee3",
"titleBar.activeForeground": "#e7e7e7",
"titleBar.inactiveBackground": "#577ee399",
"titleBar.inactiveForeground": "#e7e7e799"
},
"peacock.remoteColor": "#577ee3",
"deno.enable": true,
"deno.unstable": true,
"deno.config": "./deno.jsonc",
"svg.preview.background": "black"
"svg.preview.background": "white"
}

BIN
project-warstone/bun.lockb Executable file

Binary file not shown.

View File

@@ -17,7 +17,7 @@
"postcss": "^8.4.24",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.12.0",
"react-router-dom": "^6.21.0",
"recoil": "^0.7.7",
"recoilize": "^3.2.0",
"tailwindcss": "^3.3.2",

View File

@@ -0,0 +1,57 @@
Start query: ?
- by default, queries search the current publication
- you can specify a publication by following this with the name, by default it will use the latest release of a publication
- you can specify the version by including it in a pair of square brackets
Query current object: ^
- refers specifically to the object based off of this specific type
Query relative object: $
- refers to the last relative object in the hierarchy
Access child: .
- this also maps over all items in a list and returns a list of the relevant children
- if the parent is a list, it will return a list of just the specified child
Select: []
- Selects any of the items that matches the provided selector
- Providing a number selects the ordinal item in the list
- Providing a comparator selects items that match the comparator
- Selectors can be separated by either commas for "and" or slashes for "or", but not both
Comparators:
= is similar to
== is exactly the same
> greater than
< less than
>= greater than or equal to
<= less than or equal to
!! is true/exists
! is not
/ or, allows matching one or more comparators at the same time
Combiner: ()
- Will only select items that match all of the selectors
- Selectors can be separated by either commas for "and" or slashes for "or", but not both
--Templating--
insertion: {{}}
- Allows for queries to be wrapped in a templated string
- Can be either a direct query or used in combination with the "_" character to reference a single query
single query: <<?
- Used the same as start query, but appended to the end of a templated string
- Used in conjunction with "_" to reference the queried value
query reference: _
- used in conjunction with single query to reference the queried value
separator: ::;;
- used at the end of a template to indicate how to separate results when the results are more than one.
- Whatever text is placed between in the middle is treated as the full separator, or in other words, if the separator doesn't have any spaces, then there will be no spaces between items in the final text
- If no separator is provided, it will default to comma and space separation
- placed at the end of the template but before the single query
- should support any standard escaped characters such as newline
Examples:
?core.weapon_abilities[name=Rapid Fire].body
- this searches the publication 'core' object for weapon_abilities that have the name
?core[v1].weapon_abilities[name=Rapid Fire].body
- this searches version 'v1' of the publication 'core' object for weapon_abilities that have the name
Sustained Hits {{?^sustained_hits}}
- This formats the string with the queried values, which start the query within the same object that the field exists in. This would result in the string 'Sustained Hits 1'
Anti-{{_.keyword}} {{_.value}}<<?^anti_abilities
- This formats the string with the queried values, which start the query within the same object that the field exists in. This would result in the string 'Anti-Infantry 2+' or 'Anti-Infantry 2+, Anti-Monster 4+' if there are more than one matching values
Anti-{{_.keyword}} {{_.value}}:://;;<<?^anti_abilities
- This formats the string with the queried values, which start the query within the same object that the field exists in. This would result in the string 'Anti-Infantry 2+' or 'Anti-Infantry 2+//Anti-Monster 4+' if there are more than one matching values

View File

@@ -2,13 +2,22 @@ import { RecoilRoot } from 'recoil'
import { SchemaBuilder } from './components/SchemaBuilder'
import { GameSystemEditor } from './components/GameSystemEditor'
import RecoilizeDebugger from 'recoilize';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { SchemaList } from './components/schemalist';
function App() {
return (
<RecoilRoot>
<BrowserRouter>
<Routes>
<Route Component={SchemaList} path='/schemas' />
<Route Component={SchemaBuilder} path='/schema/:id' />
{/* <Route Component={SchemaBuilder} path='/schema/new' /> */}
<Route Component={GameSystemEditor} path='/game-system/:id' />
{/* <Route Component={GameSystemEditor} path='/game-system/new' /> */}
</Routes>
</BrowserRouter>
<RecoilizeDebugger />
{/* <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

@@ -16,19 +16,20 @@ export const GameSystemEditor: FC = () => {
name: 'Asshammer 40x a day',
accolades: [],
});
const [schemas, setSchemas] = useState<{ name: string, id: string }[]>([]);
const [schemas, setSchemas] = useState<[string,string][]>([]);
const [lastSaved, setLastSaved] = useState(gameSystem);
const fetchSchema = useCallback(async (id: string) => {
const res = await GameSystemsService.getSchema(id);
try {
const schema = await GameSystemsService.getSchema(id);
if (res.status !== 200) return;
const schema = await res.json()
updateGameSystem({
schema
});
updateGameSystem({
schema
});
} catch (e) {
console.log('failed to fetch schema:', e)
}
}, [updateGameSystem])
useEffect(() => {
@@ -38,7 +39,6 @@ export const GameSystemEditor: FC = () => {
useEffect(() => {
GameSystemsService.getSchemaList()
.then(res => res.json())
.then(schemas => setSchemas(schemas));
}, []);
@@ -48,8 +48,7 @@ export const GameSystemEditor: FC = () => {
}, [gameSystem])
const fetchGameSystem = useCallback(async () => {
const res = await GameSystemsService.getGameSystem('');
const gs = await res.json();
const gs = await GameSystemsService.getGameSystem('');
setGameSystem(gs);
setLastSaved(gs);
}, [setGameSystem]);
@@ -66,7 +65,7 @@ export const GameSystemEditor: FC = () => {
<select className="no-default text-white bg-transparent header border-b-2 border-falcon" {...bindGameSystemProperty('schemaId')}>
<option value=""></option>
{schemas.map(s => (
<option value={s.id}>{s.name}</option>
<option value={s[0]}>{s[1]}</option>
))}
</select>
<button onClick={saveGameSystem} disabled={lastSaved === gameSystem}>Save Game System</button>

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

@@ -1,50 +1,80 @@
import { FC, useCallback, useEffect } from 'react'
import { FC, useCallback, useEffect, useState } from 'react'
import { FieldType, FieldTypes, fieldTypeOptions, fieldTypesWithValues } from '../../types/schema'
import { useObjectStateWrapper } from '../../hooks/useObjectState';
import { ValueField } from './value-field';
import { HelpPopper } from '../Poppables/help';
import { Icon } from '../Icon';
import { RESERVED_FIELDS } from '../../constants/ReservedFields';
interface IProps {
update: (arg: FieldType) => void;
field: FieldType;
fieldName: string;
deleteField: (arg: string) => void;
}
export const FieldEditor: FC<IProps> = ({ update, field, fieldName }) => {
const { bindProperty, bindPropertyCheck } = useObjectStateWrapper(field, (e) => update(typeof e === 'function' ? e(field) : e))
export const FieldEditor: FC<IProps> = ({ update, field, fieldName, deleteField }) => {
const { bindProperty, bindPropertyCheck } = useObjectStateWrapper(field, (e) => update(typeof e === 'function' ? e(field) : e));
const shouldShowValueField = useCallback(() => fieldTypesWithValues.includes(field.type) || field.isConstant, [field.isConstant, field.type]);
const [reserved, setReserved] = useState<FieldType | undefined>(RESERVED_FIELDS[fieldName]);
useEffect(() => {
console.log(field.value);
}, [field])
setReserved(RESERVED_FIELDS[fieldName]);
}, [fieldName])
// useEffect(() => {
// console.log(field.value);
// }, [field])
return (
<li className="odd:bg-black/50">
<p>{fieldName}</p>
<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>
<div className="flex gap-2 items-center">
<p>{fieldName}</p>
{reserved && (
<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>
<p className="text-xs">This is a reserved field name, these exist for internal purposes, but are still useful when creating a type, and as such have specific settings that you cannot override. If you need control over the field properties, please use a different field name</p>
</HelpPopper>
</span>
<label className="w-min">
Limit:
<input className="w-12" type="number" {...bindProperty('limit')} />
</label>
)}
</div>
{!reserved && (
<div className=" flex gap-x-4 items-center p-2 w-full">
<label className="w-min">
Field Type:&nbsp;
<select className="capitalize" {...bindProperty('type')} disabled={!!reserved}>
{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">
Minimum:
<input className="w-12" type="number" {...bindProperty('minimum')} />
</label>
<label className="w-min">
Limit:
<input className="w-12" type="number" {...bindProperty('limit')} />
</label>
<HelpPopper>
<p className="text-sm">Minimum and Limit apply to the number of entries allowed for this field, not the maximum and minimum value. Set the minimum to 0 to make a field optional. Set the limit to 0 to allow for unlimited entries.</p>
</HelpPopper>
<button className="no-default self-end ml-auto" onClick={() => deleteField(fieldName)}>
<Icon className="svg-red-700 hover:svg-red-500 trash-can w-6 h-6" icon="trash"></Icon>
</button>
</div>
)}
</li>
)
}

View File

@@ -4,16 +4,23 @@ import { TypeEditor } from './type-editor';
import { useObjectState, useObjectStateWrapper } from '../../hooks/useObjectState';
import { FieldTypes, Schema, Template, TypeType } from '../../types/schema';
import { useInput } from '../../hooks/useInput';
import { useRecoilState } from 'recoil';
import { useRecoilState, useResetRecoilState } from 'recoil';
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';
import { useNavigate, useParams } from 'react-router-dom';
export const SchemaBuilder: FC = () => {
const [schema, setSchema] = useRecoilState(SchemaEditAtom);
const { update: updateSchema } = useObjectStateWrapper<Schema>(schema, setSchema);
const resetSchema = useResetRecoilState(SchemaEditAtom);
const { update: updateSchema, bindProperty:bindSchemaProperty } = useObjectStateWrapper<Schema>(schema, setSchema);
const navigate = useNavigate();
const {id} = useParams<{id: string}>()
const { value: typeName, bind: bindTypeName, reset: resetTypeName } = useInput('');
@@ -22,19 +29,18 @@ export const SchemaBuilder: FC = () => {
const [lastSaved, setLastSaved] = useState(schema);
const fetchSchema = useCallback(async () => {
const result = await GameSystemsService.getSchema('286f4c18-d280-444b-8d7e-9a3dd09f64ef')
if (result.status !== 200) return;
const fetchedSchema = await result.json();
if (!id) return;
if (id === 'new') return resetSchema();
const fetchedSchema = await GameSystemsService.getSchema(id)
// if (fetchedSchema.name === schema.name) return;
if (!fetchedSchema.templates) fetchedSchema.templates = {}
setSchema(fetchedSchema);
setLastSaved(fetchedSchema);
}, [setSchema])
}, [])
useEffect(() => {
fetchSchema();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [fetchSchema])
const [selectedType, setSelectedType] = useState('');
@@ -50,9 +56,10 @@ export const SchemaBuilder: FC = () => {
setSelectedType('');
}, [resetTypeName, updateSchema]);
const saveSchema = useCallback(() => {
GameSystemsService.saveSchema(schema);
const saveSchema = useCallback(async () => {
setLastSaved(schema);
const sid = await GameSystemsService.saveSchema(schema);
if (id === 'new') navigate('/schema/'+sid)
}, [schema])
const selectTypeForEdit = useCallback((typeKey: string) => {
@@ -60,7 +67,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,29 +90,61 @@ 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>
<input type="text" {...bindSchemaProperty('name')} placeholder="Schema Name" />
</div>
<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 key={templateKey} 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 key={'type' + t} 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} />
<div className="flex gap-2 mt-2">
<div className="flex gap-2 mb-2">
<button
onClick={saveSchema}
disabled={lastSaved === schema}
@@ -120,6 +159,7 @@ export const SchemaBuilder: FC = () => {
Discard Changes
</button>
</div>
<SchemaViewer schema={schema} onTypeClick={selectTypeForEdit} />
</div>
</div>
)

View File

@@ -1,5 +1,7 @@
import { FC, useCallback } from 'react'
import { FieldType, FieldTypes, Schema, TypeType, fieldTypesWithValues } from '../../types/schema'
import { Truncate } from '../Poppables/truncation';
import { Accordion, AccordionContent } from '../../lib/accordion';
interface IProps {
schema: Schema;
@@ -16,6 +18,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 '';
}
}, [])
@@ -38,25 +41,30 @@ export const SchemaViewer: FC<IProps> = ({ schema, onTypeClick }) => {
</ul>
<hr />
<p className="font-bold italic">Types</p>
<ul className="rounded-lg overflow-hidden">
<ul className="rounded-lg overflow-hidden grid">
{Object.entries(schema.types).map(([typeKey, type]) => (
<li
key={'type viewer' + typeKey}
onClick={() => onTypeClick && onTypeClick(typeKey, type)}
// onClick={() => onTypeClick && onTypeClick(typeKey, type)}
data-clickable={!!onTypeClick}
className="odd:bg-black/50 p-2 data-[clickable=true]:cursor-pointer"
className="odd:bg-black/50 p-2 group overflow-hidden"
>
<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>}
<Accordion
title={<p className="group-data-[expanded]/controlled:mb-2 transition-all font-bold">{typeKey}</p>}
>
<AccordionContent>
<div className="grid grid-cols-2 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)} <Truncate>{field.value}</Truncate></p>}
</div>
))}
</div>
))}
</div>
</AccordionContent>
</Accordion>
</li>
))}
</ul>

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 key={'templatetype' + k} value={k}>{k}</option>
))}
{Object.keys(schema.types).map(k => (
<option key={'schematype' + k} 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 { FormEvent, useCallback, useEffect } from 'react'
import { FCC } from '../../types'
import { FieldType, FieldTypes, TypeType } from '../../types/schema'
import { useObjectState } from '../../hooks/useObjectState';
@@ -16,20 +16,22 @@ 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);
resetType();
}
const addField = useCallback(() => {
const addField = useCallback((e: FormEvent) => {
e.preventDefault();
updateType({
[propertyName]: {
type: FieldTypes.number,
value: '',
isConstant: false,
limit: 1,
minimum: 1,
}
});
resetPropertyName();
@@ -43,14 +45,24 @@ export const TypeEditor: FCC<IProps> = ({ saveType, name, type: passedType }) =>
passedType && setType(passedType);
}, [passedType, setType]);
const deleteField = useCallback((name: string) => {
setType(t => {
const fields = {...t};
delete fields[name];
return fields;
})
}, [setType])
return (
<div>
<p className="subheader">{passedType ? 'Editing' : 'Creating'} type "{name}"</p>
<input type="text" {...bindPropertyName} />
<button disabled={!propertyName} onClick={addField}>Add Field</button>
<form onSubmit={addField}>
<input type="text" {...bindPropertyName} />
<button disabled={!propertyName}>Add Field</button>
</form>
<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} />
{Object.entries(type).reverse().filter(([k]) => !constantProperties.includes(k)).map(([key, value]) => (
<FieldEditor field={value} update={updateField(key)} fieldName={key} deleteField={deleteField} />
))}
</ul>
<div>

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;
@@ -42,7 +43,7 @@ export const ValueField: FC<IValueProps> = ({ type, bind }) => {
>
<option value=""></option>
{DICE_SIDES.map(d => (
<option value={'d' + d}>{d}</option>
<option key={'dice sides' + d} value={'d' + d}>{d}</option>
))}
</select>
</label>
@@ -60,7 +61,39 @@ export const ValueField: FC<IValueProps> = ({ type, bind }) => {
)
case FieldTypes.text:
return (
<label className="w-min">Value:<input type="number" {...bind} /></label>
<label className="w-min">Value:<input type="text" {...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

@@ -0,0 +1,20 @@
import { FC, useEffect, useState } from "react";
import { GameSystemsService } from "../services/game-systems";
import { Link } from "react-router-dom";
export const SchemaList: FC = () => {
const [schemas, setSchemas] = useState<[string,string][]>([]);
useEffect(() => {
GameSystemsService.getSchemaList().then(l => {
setSchemas(l)
});
}, [])
return (
<div className="container pt-12 flex flex-col gap-6">
<Link to="/schema/new"><button>New Schema</button></Link>
<ul className="panel">
{schemas.map(([id,name])=> <li key={id}><Link to={'/schema/' + id}><span className="w-full">{name || 'Unnamed Schema'}</span></Link></li>)}
</ul>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import { FieldType, FieldTypes } from '../types/schema';
export const RESERVED_FIELDS: Record<string, FieldType> = {
maximum: {
isConstant: false,
limit: 1,
minimum: 1,
type: FieldTypes.number,
value: ''
},
minimum: {
isConstant: false,
limit: 1,
minimum: 1,
type: FieldTypes.number,
value: ''
},
relative: {
isConstant: true,
limit: 1,
minimum: 1,
type: FieldTypes.text,
value: '$'
},
}

View File

@@ -1,70 +1,95 @@
import { FieldTypes, TypeType } from '../types/schema';
import { FieldTypes, TypeType } from "../types/schema";
export const TEMPLATE_TYPES: Record<string, TypeType> = {
section: {
name: {
isConstant: false,
limit: 1,
minimum: 1,
type: FieldTypes.text,
value: ''
value: "",
},
body: {
isConstant: false,
limit: 0,
type: FieldTypes['long text'],
value: ''
minimum: 1,
type: FieldTypes["long text"],
value: "",
},
},
steps: {
steps: {
isConstant: false,
limit: 0,
minimum: 1,
type: FieldTypes.type,
value: 'section'
}
value: "section",
},
},
image: {
name: {
isConstant: false,
limit: 1,
minimum: 1,
type: FieldTypes.text,
value: ''
value: "",
},
link: {
isConstant: false,
limit: 1,
minimum: 1,
type: FieldTypes.text,
value: ''
value: "",
},
},
list: {
items: {
isConstant: false,
limit: 0,
type: FieldTypes['long text'],
value: ''
}
minimum: 1,
type: FieldTypes["long text"],
value: "",
},
},
tableRow: {
table_column: {
name: {
isConstant: false,
limit: 0,
minimum: 1,
type: FieldTypes.any,
value: "",
},
value: {
isConstant: false,
limit: 0,
minimum: 1,
type: FieldTypes.any,
value: "",
},
},
table_row: {
columns: {
isConstant: false,
limit: 0,
type: FieldTypes.any,
value: ''
}
minimum: 1,
type: FieldTypes.type,
value: "tableColumn",
},
},
table: {
rows: {
isConstant: false,
limit: 0,
minimum: 1,
type: FieldTypes.type,
value: 'tableRow'
value: "tableRow",
},
header: {
isConstant: false,
limit: 1,
minimum: 0,
type: FieldTypes.type,
value: 'tableRow'
}
}
};
value: "tableRow",
},
},
};

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,38 +1,58 @@
import React, { ChangeEvent, useCallback, useState } from 'react';
import { InputBinder } from '../types/inputBinder';
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) => ({
value: state[property] ?? '',
name: property,
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
setState(value => (
{
...value,
[event.target.name]: (
(typeof value[property] === 'number') ?
Number(event.target.value) || 0 :
event.target.value)
}
))
}), [state])
const bindProperty = useCallback(
<K extends keyof T>(property: K, config?: ObjectStateHookConfig) => ({
value: state[property] ?? "",
name: property,
onChange: (
event: ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>,
) =>
setState((value) => (
{
...value,
[event.target.name]: (
(typeof value[property] === "number")
? Number(event.target.value) || 0
: config?.disallowSpaces
? event.target.value.replace(" ", config.spaceReplacer || "_")
: event.target.value
),
}
)),
}),
[state],
);
const bindPropertyCheck = useCallback(<K extends keyof T>(property: K) => ({
checked: !!state[property],
name: property,
onChange: (event: ChangeEvent<HTMLInputElement>) => setState(value => ({
...value, [event.target.name]: (event.target.checked)
})),
readOnly: true
}), [state])
onChange: (event: ChangeEvent<HTMLInputElement>) =>
setState((value) => ({
...value,
[event.target.name]: (event.target.checked),
})),
readOnly: true,
}), [state]);
const update = useCallback((updates: Partial<T>) => setState(s => ({ ...s, ...updates })), [])
const update = useCallback(
(updates: Partial<T>) => setState((s) => ({ ...s, ...updates })),
[],
);
const reset = useCallback(() => {
setState(initial);
}, [initial])
}, [initial]);
return {
bindProperty,
@@ -40,45 +60,67 @@ export const useObjectState = <T extends object>(initial: T) => {
update,
state,
setState,
reset
}
}
reset,
};
};
export const useObjectStateWrapper = <T extends object>(state: T, setState: React.Dispatch<React.SetStateAction<T>>) => {
const bindProperty = useCallback(<K extends keyof T>(property: K): InputBinder => ({
value: state[property] ?? '',
name: property.toString(),
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
setState(value => (
{
...value,
[event.target.name]: (
(typeof value[property] === 'number') ?
Number(event.target.value) || 0 :
event.target.value)
}
))
}), [setState, state])
export const useObjectStateWrapper = <T extends object>(
state: T,
setState: React.Dispatch<React.SetStateAction<T>>,
) => {
const bindProperty = useCallback(
<K extends keyof T>(
property: K,
config?: ObjectStateHookConfig,
): InputBinder => ({
value: state[property]?.toString() ?? "",
name: property.toString(),
onChange: (
event: ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>,
) =>
setState((value) => (
{
...value,
[event.target.name]: (
(typeof value[property] === "number")
? Number(event.target.value) || 0
: config?.disallowSpaces
? event.target.value.replace(" ", config.spaceReplacer || "_")
: event.target.value
),
}
)),
}),
[setState, state],
);
const bindPropertyCheck = useCallback(<K extends keyof T>(property: K) => ({
checked: !!state[property],
name: property,
onChange: (event: ChangeEvent<HTMLInputElement>) => setState(value => ({
...value, [event.target.name]: (event.target.checked)
})),
readOnly: true
}), [setState, state])
onChange: (event: ChangeEvent<HTMLInputElement>) =>
setState((value) => ({
...value,
[event.target.name]: (event.target.checked),
})),
readOnly: true,
}), [setState, state]);
const update = useCallback((updates: Partial<T> | ((arg: T) => Partial<T>)) =>
setState(s => ({ ...s, ...(typeof updates === 'function' ? updates(s) : updates) }
)), [setState])
const update = useCallback(
(updates: Partial<T> | ((arg: T) => Partial<T>)) =>
setState((s) => ({
...s,
...(typeof updates === "function" ? updates(s) : updates),
})),
[setState],
);
return {
bindProperty,
bindPropertyCheck,
update,
state,
setState
}
}
setState,
};
};

View File

@@ -41,8 +41,12 @@
@apply text-xl font-bold
}
button {
@apply interactive bg-olive-drab p-1
button:not(.no-default) {
@apply interactive p-1
}
button:not([class*="bg-"]):not(.no-default) {
@apply bg-olive-drab
}
.interactive {
@@ -65,6 +69,30 @@
.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,68 @@
import { FC, PropsWithChildren, ReactNode, useCallback, useState } from 'react'
import { FCC } from '../../types';
interface IProps {
expandOnHover?: boolean;
expanded?: boolean;
title?: ReactNode;
}
export const Accordion: FCC<IProps> = ({ children, expandOnHover, expanded, title }) => {
const [open, setOpen] = useState(false);
return (
<div
data-expanded={open || expanded}
data-expandonhover={expandOnHover}
className={(expandOnHover ? 'group/hover' : 'group/controlled') + ' group'}
onClick={() => !title && !expandOnHover && setOpen(!open)}
>
{!!title && (
<div className="flex justify-between cursor-pointer" onClick={() => !expandOnHover && setOpen(!open)}>
{title}
<div
className={`
group-hover/hover:-rotate-180
group-data-[expanded]:-rotate-180
transition-transform
duration-500
grid
rounded-full
h-min
mr-2
mt-1
scale-y-50
`}
>
<span className="block w-2 h-2 rotate-45 border-r-2 border-b-2 place-self-center"></span>
<span className="block w-2 h-2 rotate-45 border-r-2 border-b-2 place-self-center"></span>
</div>
</div>
)}
{children}
</div>
)
}
export const AccordionContent: FC<PropsWithChildren> = ({ children }) => {
const [height, setHeight] = useState(0);
const updateRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
setHeight(node.clientHeight)
} else {
setHeight(0)
}
}, [])
const Child = () => <div className="absolute bottom-0 w-full" ref={updateRef}>
{children}
</div>
return (
<div className="relative overflow-hidden">
{<Child />}
<span style={{ ['--v-height' as never]: height + 'px' }} className="w-0 block h-0 group-hover/hover:h-variable group-data-[expanded]/controlled:h-variable transition-all duration-700" />
</div>
);
}

View File

@@ -1,51 +1,45 @@
import { GameSystem } from '../types/gameSystem';
import { Schema } from '../types/schema';
import { GameSystem } from "../types/gameSystem";
import { Schema } from "../types/schema";
import { idb } from "./indexeddb";
const emptySchema = { name: '', types: {}, templates: {}, id: crypto.randomUUID() };
const emptySchema = {
name: "",
types: {},
templates: {},
id: crypto.randomUUID(),
};
export const GameSystemsService = {
// todo - connect to service to save schema for game
saveSchema: async (schema: Schema) => {
localStorage.setItem('schema ' + schema.id, JSON.stringify(schema));
return { status: 200 }
// localStorage.setItem('schema ' + schema.id, JSON.stringify(schema));
try {
return await idb.createOrUpdate({
storeName: "schema",
...schema,
});
} catch (e) {
console.log(e);
}
},
// todo - connect to service to fetch schema for game
getSchema: async (id: string): Promise<{ status: 200 | 404, json: () => Promise<Schema> }> => {
const schema = localStorage.getItem('schema ' + id);
if (schema)
return {
status: 200,
json: async () => JSON.parse(schema)
}
return {
status: 404,
json: async () => (emptySchema)
}
},
getSchemaList: async () => {
return {
status: 200, json: async () => [{
name: 'Test Schema',
id: '286f4c18-d280-444b-8d7e-9a3dd09f64ef'
}]
getSchema: async (
id: string,
): Promise<Schema> => {
try {
const schema = await idb.read("schema", id);
return schema;
} catch (e) {
throw e;
}
},
getSchemaList: async () => await idb.listAll<string>("schema", "name"),
saveGameSystem: async (gs: GameSystem) => {
localStorage.setItem('game-system ' + gs.id, JSON.stringify(gs));
return { status: 200 }
localStorage.setItem("game-system " + gs.id, JSON.stringify(gs));
return { status: 200 };
},
getGameSystem: async (id: string): Promise<{ status: 200 | 404, json: () => Promise<GameSystem> }> => {
const gs = localStorage.getItem('game-system ' + id);
if (!gs) return {status: 404, json:async () => ({
accolades: [],
name: '',
id: crypto.randomUUID(),
schema: emptySchema,
schemaId: ''
})}
return { status: 200, json:async () => (JSON.parse(gs))}
}
}
getGameSystem: async (
id: string,
): Promise<GameSystem> => await idb.read("game-system", id),
};

View File

@@ -0,0 +1,7 @@
import { IndexedDBService } from "../utils/indexeddb";
export const idb = new IndexedDBService("commander", 1, [
"game-system",
"publication",
"schema",
]);

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

@@ -7,6 +7,7 @@ export type FieldType = {
value: string;
isConstant: boolean;
limit: number;
minimum: number;
};
export type TypeType = Record<string, FieldType>;
@@ -30,8 +31,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];

View File

@@ -0,0 +1,175 @@
type Data = Record<string, any> & { storeName: string };
export class IndexedDBService {
private dbName: string;
private dbVersion: number;
private storeNames: string[];
private db: IDBDatabase | null = null;
constructor(dbName: string, dbVersion: number, storeNames: string[]) {
this.dbName = dbName;
this.dbVersion = dbVersion;
this.storeNames = storeNames;
}
private async openDB(): Promise<IDBDatabase> {
if (this.db) return this.db;
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => {
reject(new Error("Failed to open the database."));
};
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create all the required object stores during the upgrade
for (const storeName of this.storeNames) {
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName, { keyPath: "uuid" });
}
}
};
request.onblocked = () => {
reject(new Error("Database is blocked and cannot be accessed."));
};
});
}
private generateUUID(): string {
return crypto.randomUUID();
}
public async create(data: Data): Promise<string> {
const db = await this.openDB();
const uuid = this.generateUUID();
return new Promise((resolve, reject) => {
const transaction = db.transaction(data.storeName, "readwrite");
const objectStore = transaction.objectStore(data.storeName);
const request = objectStore.add({ ...data, uuid });
request.onsuccess = () => {
resolve(uuid);
};
request.onerror = () => {
reject(new Error("Failed to add data to IndexedDB."));
};
});
}
public async read(storeName: string, uuid: string): Promise<any | null> {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, "readonly");
const objectStore = transaction.objectStore(storeName);
const request = objectStore.get(uuid);
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(new Error("Failed to read data from IndexedDB."));
};
});
}
public async update(
storeName: string,
uuid: string,
newData: any,
): Promise<void> {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, "readwrite");
const objectStore = transaction.objectStore(storeName);
const request = objectStore.put({ ...newData, uuid });
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error("Failed to update data in IndexedDB."));
};
});
}
public async delete(storeName: string, uuid: string): Promise<void> {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, "readwrite");
const objectStore = transaction.objectStore(storeName);
const request = objectStore.delete(uuid);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error("Failed to delete data from IndexedDB."));
};
});
}
public async listAll<T = any>(
storeName: string,
fieldName: string,
): Promise<[string, T][]> {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, "readonly");
const objectStore = transaction.objectStore(storeName);
const request = objectStore.openCursor();
const results: [string, any][] = [];
request.onsuccess = (event) => {
const cursor: IDBCursorWithValue = (event.target as IDBRequest).result;
if (cursor) {
const data = cursor.value;
if (fieldName in data) {
results.push([data.uuid, data[fieldName]]);
}
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => {
reject(new Error("Failed to list data from IndexedDB."));
};
});
}
public async createOrUpdate(data: Data): Promise<string> {
// If the provided data already has a UUID, check if it exists in the database
if (data.uuid) {
const existingData = await this.read(data.storeName, data.uuid);
if (existingData) {
await this.update(data.storeName, data.uuid, data);
return data.uuid;
}
}
// Generate a new UUID for the data and use the create method
const uuid = this.generateUUID();
await this.create({ ...data, uuid, storeName: data.storeName });
return uuid;
}
}

View File

@@ -50,6 +50,9 @@ export default {
},
container: {
center: true
},
height: {
variable: 'var(--v-height)'
}
},
},