Last active
May 18, 2024 15:29
-
-
Save fmaylinch/f888755668ad83a869926ab1aefb42c0 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Last change: added UseHooks | |
const tokens = [ | |
"+1", 0, 0, -1, -1, -1, | |
-2, -2, -3, -4, | |
"💀", "💀", "🧙", "🦑", "🌟" | |
]; | |
function diceMessageGenerator(value) { | |
switch (value) { | |
case "🌟": return "Special Effect!"; | |
case "🦑": return "FAIL (and your total is 0)"; | |
case "+1": return random(["oh yeah!", "rock & roll!", "I'm on a roll!"]); | |
case -3: return random(["damn, was I prepared?", "ouch, let's count...", "did I buff myself enough?"]); | |
case -4: return random(["cruel world...", "no waaay", "this is not fair!"]); | |
//case "💀": return "-1 (fail? -3 deck cards)"; | |
//case "🧙": return "-1 (discarded 10 cards? -3)"; | |
case "💀": return "-2 (-2 resources ~> 0)"; | |
case "🧙": return "-3 (succeed? +3 resources)"; | |
case "🫀": return "-X (-2 deck cards, X = cost sum)"; | |
default: return ""; | |
} | |
} | |
const tokenDiceOptions = { | |
values: tokens, | |
rollingText: () => random([ | |
"searching bag...", "I'm feeling lucky...", "I think I will get a...", "come on pretty bag..." | |
]), | |
valueMessage: diceMessageGenerator | |
}; | |
function buildLayout(data, update) { | |
return group("arkham", [ | |
group("progress", [ | |
select("phase", _ => [ | |
"📈 mythos - doom", "🃏 mythos - cards", "🔎 investigation", "🦑 enemy", "📊 keep-up - stats", "🃏 keep-up - cards" | |
]), | |
group("stats", [ | |
counter("act", {showId: true}), | |
counter("agenda", {showId: true}), | |
counter("doom", {showId: true}) | |
], { noTitle: true }) | |
]), | |
groupAdder("places", _ => [ | |
counter("clues") | |
], { row: true, titleAtEnd: true }), | |
groupAdder("enemies", _ => [ | |
select("place", optionSources({ | |
"📍": optionsFrom("arkham.places"), | |
"👤": optionsFrom("arkham.players") | |
})), | |
rowGroup("stats", [ | |
counter("damage"), | |
counter("doom") | |
]) | |
], { drawBorder: true }), | |
group("dice", [ | |
dice("Token", tokenDiceOptions), | |
dice("Random 0..99", { | |
values: 100, | |
rollingText: () => "doing some math...", | |
}), | |
], { noTitle: true }), | |
groupAdder("players", _ => [ | |
select("place", optionSources({ | |
"📍": optionsFrom("arkham.places"), | |
})), | |
rowGroup("main", [ | |
counter("actions", {showId: true}) | |
]), | |
rowGroup("collectables", [ | |
counter("resources"), | |
counter("clues") | |
]), | |
rowGroup("life", [ | |
counter("damage"), | |
counter("horror") | |
]), | |
adder("items", (id, value) => [ | |
group(id, value.isAlly | |
? [ counter("damage"), counter("horror") ] | |
: [ counter("uses") ], | |
{ row: true, titleAtEnd: !value.isAlly, clearable: true }) | |
], { inputParser: input => { | |
// if input is "dog ally", the id will be "dog", and value `{isAlly:true}` | |
const [id, type] = input.split(" "); | |
return {id, value: {isAlly: type === "ally"}}; | |
}, hint: "e.g. gun, dog ally" } | |
) | |
], { // options for group "players" | |
drawBorder: true, | |
inputParser: initialValue( { | |
main: { actions: 3 }, | |
collectables: { resources: 5 }, | |
life: { damage: 6, horror: 6 } } ) | |
}), | |
separator(), | |
textArea("notes"), | |
separator(), | |
logger("log"), | |
button("clear log", () => update("arkham.log", [])) | |
]); | |
} | |
toastr.options.closeDuration = 10000; | |
function customDataChanges(path, value, data) { | |
if (path === "arkham.progress.phase") { | |
if (value === "📈 mythos - doom") { | |
// doom +1 | |
const doomPath = "arkham.progress.stats.doom"; | |
const doom = getValue(data, doomPath) || 0; | |
const newData = updateDataPathAndLog(data, doomPath, doom + 1); | |
toastr.info(`Doom: ${doom} -> ${doom + 1}`); | |
toastr.warning(`Doom limit reached?`); | |
return newData; | |
} else if (value === "🃏 mythos - cards") { | |
toastr.warning("Draw encounter cards"); | |
} else if (value === "🦑 enemy") { | |
ensurePlayersUsedAllActions(data); | |
} else if (value === "📊 keep-up - stats") { | |
if (!ensurePlayersUsedAllActions(data)) { | |
return data; // ignore changes | |
} | |
// reset actions, +1 resource | |
let newData = data; | |
const playerIds = getObjectKeys(data, "arkham.players"); | |
for (let playerId of playerIds) { | |
const actionsPath = `arkham.players.${playerId}.main.actions`; | |
const resourcesPath = `arkham.players.${playerId}.collectables.resources`; | |
const resources = getValue(data, resourcesPath) || 0; | |
newData = updateDataPathAndLog(newData, actionsPath, 3); | |
newData = updateDataPathAndLog(newData, resourcesPath, resources + 1); | |
toastr.info(`${playerId} resources: ${resources} -> ${resources + 1}`) | |
} | |
toastr.info("Actions reset"); | |
return newData; | |
} else if (value === "🃏 keep-up - cards") { | |
toastr.warning("Draw cards"); | |
} | |
} | |
return data; | |
} | |
function ensurePlayersUsedAllActions(data) { | |
const playerIds = getObjectKeys(data, "arkham.players"); | |
let foundError = false; | |
for (let playerId of playerIds) { | |
const actionsPath = `arkham.players.${playerId}.main.actions`; | |
const actions = getValue(data, actionsPath) || 0; | |
if (actions != 0) { | |
toastr.error(`${playerId} has still ${actions} actions`); | |
foundError = true; | |
} | |
} | |
return !foundError; | |
} | |
function updateDataPathAndLog(data, path, value) { | |
const oldValue = getValue(data, path); | |
const newData = updateDataPath(data, path, value); | |
return updateLog(path, value, newData, oldValue); | |
} | |
function updateLog(path, value, data, oldValue) { | |
if (path == "arkham.log" || path == "arkham.notes" || path == "remoteStorageId") { | |
return data; | |
} | |
if (value && typeof value != "number" && typeof value != "string") { | |
console.log("not logging value", value); | |
return data; | |
} | |
const newEntry = {path, oldValue, value}; | |
const logPath = "arkham.log" | |
const log = getValue(data, logPath) || []; | |
const [firstEntry, ...rest] = log; | |
let newLog; | |
if (firstEntry && firstEntry.path === newEntry.path) { | |
// merge entries of same path | |
const mergedEntry = {...newEntry, oldValue: firstEntry.oldValue}; | |
if (mergedEntry.oldValue != mergedEntry.value) { | |
newLog = [mergedEntry, ...rest]; // replace entry with merged entry | |
} else { | |
newLog = rest; // remove entry, because the change was undone | |
} | |
} else { | |
newLog = [newEntry, ...log]; // add new entry normally | |
} | |
return updateDataPath(data, logPath, newLog); | |
} | |
function updateDataPath(data, path, value) { | |
const ids = path.split("."); | |
return updateNestedProperty(data, ids, value); | |
} | |
// sets a value in a nested property (`ids` is an array of properties) | |
// e.g. updateNestedProperty(obj, ["zoey", "damage"], 2) will return `obj` with also `{zoey: {damage: 2}}` | |
function updateNestedProperty(obj, ids, value) { | |
const [id, ...otherIds] = ids; | |
if (otherIds.length === 0) { | |
if (value) { | |
return {...obj, [id]: value}; | |
} else { | |
// we remove falsy values (false, null, 0, etc) | |
let { [id]: _, ...restOfObj } = obj; // remove id property | |
return restOfObj; | |
} | |
} else { | |
return {...obj, [id]: updateNestedProperty(obj?.[id], otherIds, value)} | |
} | |
} | |
// imports don't work here, but React and UseHooks are already imported | |
const { useState, createContext, useContext, useEffect } = React; | |
const { useDebounce } = UseHooks; | |
const UpdateContext = createContext(null); // will be used to provide `updateData` function | |
const DataContext = createContext(null); // will be used to provide `data` | |
function App() { | |
const [data, setData] = useState({}); | |
const [rawData, setRawData] = useState(""); | |
// local storage | |
const [lastSaveDate, setLastSaveDate] = useState(null); | |
const [isSavingEnabled, setSavingEnabled] = useState(false); | |
const debouncedData = useDebounce(data, 2000); | |
// remote storage | |
const [remoteStorageMessage, setRemoteStorageMessage] = useState(""); | |
// --- local storage management --- | |
const dataKey = "arkham"; | |
useEffect(() => { | |
console.log("loading data from local storage..."); | |
let dataFromStorage = localStorage.getItem(dataKey); | |
let loadedData = JSON.parse(dataFromStorage || "{}") | |
console.log("loaded data from local storage", loadedData); | |
setDataWithSaving(loadedData, false); | |
}, []); | |
useEffect(() => { | |
if (!isSavingEnabled) { | |
console.log("saving to local storage not enabled yet"); | |
return; | |
} | |
console.log("saving data to local storage", debouncedData); | |
localStorage.setItem(dataKey, JSON.stringify(debouncedData || {})); | |
setLastSaveDate(new Date()); | |
}, [debouncedData]); | |
// --- data management --- | |
// This function is provided via UpdateContext | |
function updateData(path, value) { | |
const updatedData = updateDataPathAndLog(data, path, value); | |
const newData = customDataChanges(path, value, updatedData, data); | |
setDataWithSaving(newData, true); | |
} | |
function setDataWithSaving(newData, enableSaving) { | |
setData(newData); | |
setSavingEnabled(enableSaving); | |
setRawData(prettyStringify(newData)); | |
} | |
function updateDataFromRawData() { | |
const newData = rawData.trim() ? JSON.parse(rawData) : {}; | |
setDataWithSaving(newData, true); // we will set rawData again, but it should be ok | |
} | |
// --- remote storage --- | |
const remoteStorageInstanceId = 'fmaylinch.github.io/arkham-horror-lgc-tracker'; | |
const remoteStorageIdPath = "remoteStorageId"; | |
function loadDataFromRemoteStorage() { | |
const remoteStorageId = data[remoteStorageIdPath]; | |
if (!remoteStorageId) { | |
setRemoteStorageMessage("Storage ID is required") | |
return; | |
} | |
setRemoteStorageMessage("Loading data...") | |
const remoteStorage = new RemoteStorage({ | |
userId: remoteStorageId, | |
instanceId: remoteStorageInstanceId | |
}) | |
remoteStorage.getItem('data').then(newData => { | |
if (newData) { | |
setRemoteStorageMessage("Remote data loaded") | |
setDataWithSaving(newData, true); | |
} else { | |
setRemoteStorageMessage("Remote data not found"); | |
} | |
}); | |
} | |
function saveDataToRemoteStorage() { | |
const remoteStorageId = data[remoteStorageIdPath]; | |
if (!remoteStorageId) { | |
setRemoteStorageMessage("Storage ID is required") | |
return; | |
} | |
setRemoteStorageMessage("Saving data...") | |
const remoteStorage = new RemoteStorage({ | |
userId: remoteStorageId, | |
instanceId: remoteStorageInstanceId | |
}) | |
remoteStorage.setItem('data', data).then(() => { | |
setRemoteStorageMessage("Remote data saved"); | |
}); | |
} | |
// --- tracker setup --- | |
// builds a root group | |
function main(builder) { | |
const id = builder.id; | |
return ( | |
<UpdateContext.Provider value={updateData}> | |
<DataContext.Provider value={data}> | |
<div style={{padding: "10px"}}> | |
{/* TODO - fix CSS so some children like textarea() can take 100% of width in phone */} | |
{ builder.build(id, data?.[id]) } | |
<hr style={{marginTop: 20}} /> | |
<div style={{width: "100%", display: "flex", flexDirection: "column", alignItems: "stretch"}}> | |
<div style={{marginBottom: 10}}>Last local save date: {lastSaveDate ? "" + lastSaveDate : ""}</div> | |
<div style={{marginBottom: 20}}> | |
<div> | |
Remote storage ID: | |
<input value={data[remoteStorageIdPath] || ""} style={{marginLeft: 10}} | |
onChange={e => updateData(remoteStorageIdPath, e.target.value)}/> | |
</div> | |
<div> | |
<button onClick={loadDataFromRemoteStorage}>Load</button> | |
<button onClick={saveDataToRemoteStorage}>Save</button> | |
<span style={{marginLeft: 10}}>{remoteStorageMessage}</span> | |
</div> | |
</div> | |
<div> | |
<button onClick={updateDataFromRawData}>Update UI from this JSON</button> | |
</div> | |
<textarea rows={10} value={rawData} onChange={e => setRawData(e.target.value)} /> | |
</div> | |
</div> | |
</DataContext.Provider> | |
</UpdateContext.Provider> | |
); | |
} | |
return ( | |
main(buildLayout(data, updateData)) | |
); | |
} | |
// These are builders: adder(), groupAdder(), group(), counter(), select(), etc. | |
// They return an object with { id, build }. | |
// The `id` is the identifier of the object (e.g. "damage", "rex", "players"). | |
// The `build` is a builder method that takes (path, value). | |
// - The `path` is something like "arkham.enemies.rat.damage". | |
// - The `value` is the data associated with that path (it can be a simple value or an object). | |
// For example, a counter can receive path = "arkham.enemies.rat.damage" and value = 1. | |
// A group can receive path = "arkham.players.rex" and value = {place: "bar", stats:{damage:2}} | |
function adder(id, buildersGenerator, options = {}) { | |
const build = (path, value) => { | |
// value may contain existing items (added before) | |
let builders = []; | |
if (value) { | |
for (let id of Object.keys(value)) { | |
builders = [...builders, ...buildersGenerator(id, value[id])]; | |
} | |
} | |
return group(id, [...builders, adderInput(id, options.inputParser, options.hint)]).build(path, value); | |
}; | |
return { id, build }; | |
} | |
function adderInput(id, inputParser, hint) { | |
// build method will receive an undefined value (because builder.id = ""), | |
// and the path will be like "arkham.players". | |
// See what Group does when `id` is "" (falsy). | |
const build = (path, _) => ( | |
<AdderInput name={id} path={path} hint={hint} | |
inputParser={inputParser ?? initialValue({})} /> | |
); | |
return { id: "", build }; | |
} | |
// convenience inputParser that provides a fixed initial value, and uses the input as id | |
function initialValue(value) { | |
return input => ({id: input, value}); | |
} | |
// the inputParser (from the input entered), decides the id and value of the new element | |
function AdderInput({ name, path, hint, inputParser }) { | |
const updateData = useContext(UpdateContext); | |
const [input, setInput] = useState(""); | |
// path contains e.g. "arkham.players", so if input is "zoey", | |
// in updateData() we will send "arkham.players.zoey" | |
return ( | |
<div style={{margin: "0 10px", display: "flex"}}> | |
<input style={{margin: "0 7px", padding: "0 10px"}} value={input} | |
placeholder={hint ? hint : `add ${name}`} | |
onChange={e => setInput(e.target.value)} /> | |
<button style={{padding: "0 10px"}} onClick={() => { | |
if (!input) return; | |
const {id, value} = inputParser(input); | |
// path already ends with ".", so we just append the id | |
updateData(path + "." + id, value); | |
setInput(""); | |
}}>+</button> | |
</div> | |
); | |
} | |
// convenience, for adders that add single groups, and with {clearable: true} in the group options. | |
function groupAdder(id, buildersGenerator, options) { | |
return adder(id, (id, value) => [ | |
group(id, buildersGenerator(id), {...options, clearable: true}) | |
], options); | |
} | |
function group(id, builders, options = {}) { | |
const build = (path, value) => ( | |
<Group name={id} path={path} options={options} > | |
{ builders.map((builder, index) => ( | |
<div key={builder.id || index}> | |
{ builder.build( | |
builder.id ? `${path}.${builder.id}` : path, | |
builder.id ? value?.[builder.id] : value) | |
} | |
</div> | |
)) } | |
</Group> | |
); | |
return { id, build }; | |
} | |
// Convenience, for typical row group | |
function rowGroup(id, builders, options) { | |
const fullOptions = {...options, row: true, noTitle: true}; | |
return group(id, builders, fullOptions); | |
} | |
// TODO - can we extract some layout logic out of here? (e.g. the title) | |
function Group({ name, path, options, children }) { | |
const updateData = useContext(UpdateContext); | |
const titleAndButton = !options.noTitle && ( | |
<div key="$title" style={options.titleAtEnd ? (options.row ? {marginLeft: "5px"} : {marginTop: "5px"}) : {marginBottom: "5px"}}> | |
<span>{name}</span> | |
{options.clearable && | |
<button style={{padding: "0 10px", borderRadius: "5px", marginLeft: "7px", backgroundColor: "#cc2222"}} | |
onClick={() => updateData(path, null)}>X</button> | |
} | |
</div> | |
); | |
const boxStyle = options.drawBorder ? {border: "1px rgba(255, 255, 255, 0.15) solid", padding: 7} : {}; | |
return ( | |
<div style={{margin: 7, display: "flex"}}> | |
<div style={{display: "flex", ...boxStyle}}> | |
<div> | |
{!options.titleAtEnd && titleAndButton } | |
<div style={{display: "flex", flexDirection: options.row ? "row" : "column"}}> | |
{[...children, options.titleAtEnd && titleAndButton]} | |
</div> | |
</div> | |
</div> | |
</div> | |
) | |
} | |
function button(id, callback) { | |
const build = (p, _) => ( | |
<button onClick={callback}>{id}</button> | |
); | |
return { id, build }; | |
} | |
function dice(id, options) { | |
const build = (p, _) => <Dice title={id} options={options} />; | |
return { id, build }; | |
} | |
function Dice({ title, options }) { | |
const [value, setValue] = useState(""); | |
const [rollingMessage, setRollingMessage] = useState(""); | |
const [message, setMessage] = useState(""); | |
function throwDice() { | |
setValue(""); | |
setMessage(""); | |
setRollingMessage(options.rollingText()); | |
setTimeout(() => { | |
setRollingMessage(""); | |
const rnd = random(options.values); | |
setValue(rnd); | |
if (options.valueMessage) { | |
const message = options.valueMessage(rnd); | |
setTimeout(() => setMessage(message), 1000); | |
} | |
}, 1000); | |
} | |
return ( | |
<div style={{margin: "5px 0"}}> | |
<button style={{marginRight: 10}} onClick={throwDice}>{title}</button> | |
<span>{rollingMessage}</span> | |
<span style={{fontSize: 18, color: "#DED3FF"}}>{value}</span> | |
<span style={{marginLeft: 10, color: "rgba(255, 255, 255, 0.45)"}}>{message}</span> | |
</div> | |
) | |
} | |
function textArea(id) { | |
const build = (p, v) => <TextArea path={p} value={v} />; | |
return { id, build }; | |
} | |
function TextArea({ path, value }) { | |
const updateData = useContext(UpdateContext); | |
const val = value || ""; | |
return ( | |
<textarea | |
placeholder={getId(path)} rows={5} style={{width: 300}} | |
value={val} onChange={e => updateData(path, e.target.value)} /> | |
) | |
} | |
function logger(id) { | |
const build = (p, v) => <Logger path={p} value={v} />; | |
return { id, build }; | |
} | |
function Logger({ path, value }) { | |
const val = value || []; | |
const renderedValue = val.map(e => `${simplifyPath(e.path)}: ${e.oldValue || "∅"} ~> ${e.value || "∅"}`).join("\n"); | |
return ( | |
<textarea | |
disabled | |
placeholder={getId(path)} rows={5} style={{width: 300}} | |
value={renderedValue} /> | |
) | |
} | |
const knownPaths = [ | |
"arkham.players.", | |
"arkham.enemies.", | |
"arkham.places.", | |
"arkham.progress.", | |
"stats.", | |
"life.", | |
"main.", | |
"items.", | |
"collectables." | |
]; | |
function simplifyPath(path) { | |
return knownPaths.reduce((p, k) => p.replace(k, ""), path); | |
} | |
function counter(id, options = {}) { | |
const build = (p, v) => <Counter path={p} value={v} options={options} />; | |
return { id, build }; | |
} | |
function Counter({ path, value, options }) { | |
const val = value || 0; // 0 by default | |
const updateData = useContext(UpdateContext); | |
const color = getCounterColor(path, val); | |
return ( | |
<div style={{margin: "0 10px", display: "flex"}}> | |
<button style={{padding: "0 10px"}} onClick={() => updateData(path, val - 1)}>-</button> | |
<span style={{margin: "0 7px", padding: "0 10px", backgroundColor: color}}>{val}</span> | |
<button style={{padding: "0 10px"}} onClick={() => updateData(path, val + 1)}>+</button> | |
{options.showId && | |
<span style={{marginLeft: "7px"}}>{getId(path)}</span> | |
} | |
</div> | |
); | |
} | |
function select(id, optionSupplier) { | |
const build = (p, v) => <Select title={id} path={p} value={v} optionSupplier={optionSupplier} />; | |
return { id, build }; | |
} | |
// optionSupplier resulting of multiple suppliers (as map) | |
function optionSources(supplierMap) { | |
return data => { | |
let result = []; | |
for (const key in supplierMap) { | |
const subResult = supplierMap[key](data).map(o => `${key}: ${o}`); | |
result = [...result, ...subResult]; | |
} | |
return result; | |
} | |
} | |
// optionSupplier gets options from ids of data in given path | |
function optionsFrom(path) { | |
return data => getObjectKeys(data, path); | |
} | |
function getObjectKeys(data, path) { | |
const d = getValue(data, path); | |
return Object.keys(d ?? {}); | |
} | |
function getValue(data, path) { | |
const ids = path.split("."); | |
let d = data; | |
for (let id of ids) { | |
d = d?.[id]; | |
} | |
return d; | |
} | |
function Select({ title, path, value, optionSupplier }) { | |
const updateData = useContext(UpdateContext); | |
const data = useContext(DataContext); | |
const val = value || ""; | |
const realOptions = optionSupplier(data); | |
const optionNames = ["", ...realOptions]; | |
const options = optionNames.map(option => <option key={option}>{option}</option>); | |
if (value && realOptions.indexOf(value) < 0) { | |
// This happens when one of the selected options is removed (when options are dynamic) | |
console.log(`Clearing select ${path} because ${value} doesn't exist in ${realOptions}`); | |
updateData(path, null); | |
} | |
function moveToNextOption() { | |
const index = optionNames.indexOf(val); | |
const newValue = optionNames[index + 1]; | |
updateData(path, newValue); // no problem if we get to an undefined index | |
} | |
return ( | |
<div> | |
{/* <span style={{marginRight: "5px"}}>{title}</span> */} | |
<select value={val} onChange={e => updateData(path, e.target.value)}> | |
{ options } | |
</select> | |
<button style={{marginLeft: 5}} onClick={moveToNextOption}>Next</button> | |
</div> | |
); | |
} | |
function separator() { | |
const build = (p, v) => <hr style={{margin: 20}} />; | |
return { id: "", build }; | |
} | |
const colors = { | |
clues: "#008000", | |
damage: "#990000", | |
doom: "#9f1da5", | |
horror: "#0052cc", | |
location: "#084408", | |
phase: "#b74e91", | |
resources: "#663300", | |
uses: "#bb6622" | |
}; | |
function getId(path) { | |
return path.split(".").at(-1); | |
} | |
function getCounterColor(path, value) { | |
const defaultColor = "#312450"; | |
if (value < 0) { | |
return "gray"; | |
} | |
const result = colors[getId(path)] || defaultColor; | |
return value == 0 ? result + "80" : result; | |
} | |
function prettyStringify(data) { | |
return JSON.stringify(data, null, 2); | |
} | |
// Returns a random integer number between 0 and n-1. | |
// If n is an array, returns a random element of n. | |
function random(n) { | |
if (Array.isArray(n)) { | |
return n[random(n.length)] | |
} | |
return Math.floor(Math.random() * n) | |
} | |
addCustomStyle(` | |
button { | |
border: 1px rgb(60, 43, 100) solid; | |
border-top: 1px gray solid; | |
border-left: 1px gray solid; | |
} | |
select { | |
padding: 5px; | |
} | |
hr { | |
border: 1px #ffffff20 solid; | |
} | |
::placeholder { | |
color: white; | |
opacity: 0.3; | |
} | |
`); | |
function addCustomStyle(css) { | |
const styleId = 'style'; | |
let style = document.getElementById(styleId); | |
if (!style) { | |
style = document.createElement('style'); | |
style.setAttribute('id', styleId); | |
style.type = 'text/css'; | |
document.head.appendChild(style); | |
} | |
style.textContent = css; | |
} | |
reactRoot.render(<App />); | |
// export default App |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment