upload files
This commit is contained in:
@@ -171,6 +171,8 @@ export const sortBy = (...iterateeFns) => array => {
|
||||
return mappedArray;
|
||||
};
|
||||
|
||||
export const sort = sortBy();
|
||||
|
||||
/**
|
||||
* A fast implementation of reduce.
|
||||
*/
|
||||
@@ -235,6 +237,8 @@ export const uniqBy = iterateeFn => array => {
|
||||
return result;
|
||||
};
|
||||
|
||||
export const uniq = uniqBy();
|
||||
|
||||
/**
|
||||
* Creates an array of grouped elements, the first of which contains
|
||||
* the first elements of the given arrays, the second of which contains
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 watermelon914 (https://github.com/watermelon914)
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { clamp01 } from 'common/math';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { Box, Button, Section, Input, Stack } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
const ARROW_KEY_UP = 38;
|
||||
const ARROW_KEY_DOWN = 40;
|
||||
|
||||
let lastScrollTime = 0;
|
||||
|
||||
export const ListInput = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const {
|
||||
title,
|
||||
message,
|
||||
buttons,
|
||||
timeout,
|
||||
} = data;
|
||||
|
||||
// Search
|
||||
const [showSearchBar, setShowSearchBar] = useLocalState(
|
||||
context, 'search_bar', false);
|
||||
const [displayedArray, setDisplayedArray] = useLocalState(
|
||||
context, 'displayed_array', buttons);
|
||||
|
||||
// KeyPress
|
||||
const [searchArray, setSearchArray] = useLocalState(
|
||||
context, 'search_array', []);
|
||||
const [searchIndex, setSearchIndex] = useLocalState(
|
||||
context, 'search_index', 0);
|
||||
const [lastCharCode, setLastCharCode] = useLocalState(
|
||||
context, 'last_char_code', null);
|
||||
|
||||
// Selected Button
|
||||
const [selectedButton, setSelectedButton] = useLocalState(
|
||||
context, 'selected_button', buttons[0]);
|
||||
|
||||
const handleKeyDown = e => {
|
||||
e.preventDefault();
|
||||
if (lastScrollTime > performance.now()) {
|
||||
return;
|
||||
}
|
||||
lastScrollTime = performance.now() + 125;
|
||||
|
||||
if (e.keyCode === ARROW_KEY_UP || e.keyCode === ARROW_KEY_DOWN) {
|
||||
let direction = 1;
|
||||
if (e.keyCode === ARROW_KEY_UP) direction = -1;
|
||||
|
||||
let index = 0;
|
||||
for (index; index < buttons.length; index++) {
|
||||
if (buttons[index] === selectedButton) break;
|
||||
}
|
||||
index += direction;
|
||||
if (index < 0) index = buttons.length - 1;
|
||||
else if (index >= buttons.length) index = 0;
|
||||
setSelectedButton(buttons[index]);
|
||||
setLastCharCode(null);
|
||||
document.getElementById(buttons[index]).focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const charCode = String.fromCharCode(e.keyCode).toLowerCase();
|
||||
if (!charCode) return;
|
||||
|
||||
let foundValue;
|
||||
if (charCode === lastCharCode && searchArray.length > 0) {
|
||||
const nextIndex = searchIndex + 1;
|
||||
|
||||
if (nextIndex < searchArray.length) {
|
||||
foundValue = searchArray[nextIndex];
|
||||
setSearchIndex(nextIndex);
|
||||
}
|
||||
else {
|
||||
foundValue = searchArray[0];
|
||||
setSearchIndex(0);
|
||||
}
|
||||
}
|
||||
else {
|
||||
const resultArray = displayedArray.filter(value =>
|
||||
value.substring(0, 1).toLowerCase() === charCode
|
||||
);
|
||||
|
||||
if (resultArray.length > 0) {
|
||||
setSearchArray(resultArray);
|
||||
setSearchIndex(0);
|
||||
foundValue = resultArray[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (foundValue) {
|
||||
setLastCharCode(charCode);
|
||||
setSelectedButton(foundValue);
|
||||
document.getElementById(foundValue).focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Window
|
||||
title={title}
|
||||
width={325}
|
||||
height={325}>
|
||||
{timeout !== undefined && <Loader value={timeout} />}
|
||||
<Window.Content>
|
||||
<Stack fill vertical>
|
||||
<Stack.Item grow>
|
||||
<Section
|
||||
fill
|
||||
scrollable
|
||||
className="ListInput__Section"
|
||||
title={message}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
buttons={(
|
||||
<Button
|
||||
compact
|
||||
icon="search"
|
||||
color="transparent"
|
||||
selected={showSearchBar}
|
||||
tooltip="Search Bar"
|
||||
tooltipPosition="left"
|
||||
onClick={() => {
|
||||
setShowSearchBar(!showSearchBar);
|
||||
setDisplayedArray(buttons);
|
||||
}}
|
||||
/>
|
||||
)}>
|
||||
{displayedArray.map(button => (
|
||||
<Button
|
||||
key={button}
|
||||
fluid
|
||||
color="transparent"
|
||||
id={button}
|
||||
selected={selectedButton === button}
|
||||
onClick={() => {
|
||||
if (selectedButton === button) {
|
||||
act("choose", { choice: button });
|
||||
}
|
||||
else {
|
||||
setSelectedButton(button);
|
||||
}
|
||||
setLastCharCode(null);
|
||||
}}>
|
||||
{button}
|
||||
</Button>
|
||||
))}
|
||||
</Section>
|
||||
</Stack.Item>
|
||||
{showSearchBar && (
|
||||
<Stack.Item>
|
||||
<Input
|
||||
fluid
|
||||
onInput={(e, value) => setDisplayedArray(
|
||||
buttons.filter(val => (
|
||||
val.toLowerCase().search(value.toLowerCase()) !== -1
|
||||
))
|
||||
)}
|
||||
/>
|
||||
</Stack.Item>
|
||||
)}
|
||||
<Stack.Item>
|
||||
<Stack textAlign="center">
|
||||
<Stack.Item grow basis={0}>
|
||||
<Button
|
||||
fluid
|
||||
color="good"
|
||||
lineHeight={2}
|
||||
content="Confirm"
|
||||
disabled={selectedButton === null}
|
||||
onClick={() => act("choose", { choice: selectedButton })}
|
||||
/>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow basis={0}>
|
||||
<Button
|
||||
fluid
|
||||
color="bad"
|
||||
lineHeight={2}
|
||||
content="Cancel"
|
||||
onClick={() => act("cancel")}
|
||||
/>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
export const Loader = props => {
|
||||
const { value } = props;
|
||||
return (
|
||||
<div className="ListInput__Loader">
|
||||
<Box
|
||||
className="ListInput__LoaderProgress"
|
||||
style={{
|
||||
width: clamp01(value) * 100 + '%',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useBackend } from '../backend';
|
||||
import { Box, Button, Icon, Section, Stack } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
export const OutfitEditor = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { outfit, saveable, dummy64 } = data;
|
||||
return (
|
||||
<Window
|
||||
width={380}
|
||||
height={600}>
|
||||
<Window.Content>
|
||||
<Box
|
||||
as="img"
|
||||
fillPositionedParent
|
||||
width="100%"
|
||||
height="100%"
|
||||
opacity={0.5}
|
||||
py={3}
|
||||
src={`data:image/jpeg;base64,${dummy64}`}
|
||||
style={{
|
||||
'-ms-interpolation-mode': 'nearest-neighbor',
|
||||
}} />
|
||||
<Section
|
||||
fill
|
||||
title={
|
||||
<Stack>
|
||||
<Stack.Item grow={1}
|
||||
style={{
|
||||
'overflow': 'hidden',
|
||||
'white-space': 'nowrap',
|
||||
'text-overflow': 'ellipsis',
|
||||
}}>
|
||||
<Button
|
||||
ml={0.5}
|
||||
color="transparent"
|
||||
icon="pencil-alt"
|
||||
title="Rename this outfit"
|
||||
onClick={() => act("rename", {})} />
|
||||
{outfit.name}
|
||||
</Stack.Item>
|
||||
<Stack.Item align="end" shrink={0}>
|
||||
<Button
|
||||
color="transparent"
|
||||
icon="info"
|
||||
tooltip="Ctrl-click a button to select *any* item instead of what will probably fit in that slot."
|
||||
tooltipPosition="bottom-left" />
|
||||
<Button
|
||||
icon="code"
|
||||
tooltip="Edit this outfit on a VV window"
|
||||
tooltipPosition="bottom-left"
|
||||
onClick={() => act("vv")} />
|
||||
<Button
|
||||
color={!saveable && "bad"}
|
||||
icon={saveable ? "save" : "trash-alt"}
|
||||
tooltip={saveable
|
||||
? "Save this outfit to the custom outfit list"
|
||||
: "Remove this outfit from the custom outfit list"}
|
||||
tooltipPosition="bottom-left"
|
||||
onClick={() => act(saveable ? "save" : "delete")} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
}>
|
||||
<Box textAlign="center">
|
||||
<Stack mb={2}>
|
||||
<OutfitSlot name="Headgear" icon="hard-hat" slot="head" />
|
||||
<OutfitSlot name="Glasses" icon="glasses" slot="glasses" />
|
||||
<OutfitSlot name="Ears" icon="headphones-alt" slot="ears" />
|
||||
</Stack>
|
||||
<Stack mb={2}>
|
||||
<OutfitSlot name="Neck" icon="stethoscope" slot="neck" />
|
||||
<OutfitSlot name="Mask" icon="theater-masks" slot="mask" />
|
||||
</Stack>
|
||||
<Stack mb={2}>
|
||||
<OutfitSlot name="Uniform" icon="tshirt" slot="uniform" />
|
||||
<OutfitSlot name="Suit" icon="user-tie" slot="suit" />
|
||||
<OutfitSlot name="Gloves" icon="mitten" slot="gloves" />
|
||||
</Stack>
|
||||
<Stack mb={2}>
|
||||
<OutfitSlot name="Suit Storage" icon="briefcase-medical" slot="suit_store" />
|
||||
<OutfitSlot name="Back" icon="shopping-bag" slot="back" />
|
||||
<OutfitSlot name="ID" icon="id-card-o" slot="id" />
|
||||
</Stack>
|
||||
<Stack mb={2}>
|
||||
<OutfitSlot name="Belt" icon="band-aid" slot="belt" />
|
||||
<OutfitSlot name="Left Hand" icon="hand-paper" slot="l_hand" />
|
||||
<OutfitSlot name="Right Hand" icon="hand-paper" slot="r_hand" />
|
||||
</Stack>
|
||||
<Stack mb={2}>
|
||||
<OutfitSlot name="Shoes" icon="socks" slot="shoes" />
|
||||
<OutfitSlot name="Left Pocket" icon="envelope-open-o" iconRot={180} slot="l_pocket" />
|
||||
<OutfitSlot name="Right Pocket" icon="envelope-open-o" iconRot={180} slot="r_pocket" />
|
||||
</Stack>
|
||||
</Box>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
const OutfitSlot = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { name, icon, iconRot, slot } = props;
|
||||
const { outfit } = data;
|
||||
const currItem = outfit[slot];
|
||||
return (
|
||||
<Stack.Item grow={1} basis={0}>
|
||||
<Button fluid height={2}
|
||||
bold
|
||||
// todo: intuitive way to clear items
|
||||
onClick={e => act(e.ctrlKey ? "ctrlClick" : "click", { slot })} >
|
||||
<Icon name={icon} rotation={iconRot} />
|
||||
{name}
|
||||
</Button>
|
||||
<Box height="32px">
|
||||
{currItem?.sprite && (
|
||||
<>
|
||||
<Box
|
||||
as="img"
|
||||
src={`data:image/jpeg;base64,${currItem?.sprite}`}
|
||||
title={currItem?.desc}
|
||||
style={{
|
||||
'-ms-interpolation-mode': 'nearest-neighbor',
|
||||
}} />
|
||||
<Icon
|
||||
position="absolute"
|
||||
name="times"
|
||||
color="label"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => act("clear", { slot })} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
color="label"
|
||||
style={{
|
||||
'overflow': 'hidden',
|
||||
'white-space': 'nowrap',
|
||||
'text-overflow': 'ellipsis',
|
||||
}}
|
||||
title={currItem?.path}>
|
||||
{currItem?.name || "Empty"}
|
||||
</Box>
|
||||
</Stack.Item>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useBackend } from '../backend';
|
||||
import { Button, Section, Stack } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
export const OutfitManager = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { outfits } = data;
|
||||
return (
|
||||
<Window
|
||||
title="Outfit Manager"
|
||||
width={300}
|
||||
height={300}>
|
||||
<Window.Content>
|
||||
<Section
|
||||
fill
|
||||
scrollable
|
||||
title="Custom Outfit Manager"
|
||||
buttons={
|
||||
<>
|
||||
<Button
|
||||
icon="file-upload"
|
||||
tooltip="Load an outfit from a file"
|
||||
tooltipPosition="left"
|
||||
onClick={() => act("load")} />
|
||||
<Button
|
||||
icon="copy"
|
||||
tooltip="Copy an already existing outfit"
|
||||
tooltipPosition="left"
|
||||
onClick={() => act("copy")} />
|
||||
<Button
|
||||
icon="plus"
|
||||
tooltip="Create a new outfit"
|
||||
tooltipPosition="left"
|
||||
onClick={() => act("new")} />
|
||||
</>
|
||||
}>
|
||||
<Stack vertical>
|
||||
{outfits?.map(outfit => (
|
||||
<Stack.Item key={outfit.ref}>
|
||||
<Stack>
|
||||
<Stack.Item grow={1} shrink={1}
|
||||
style={{
|
||||
'overflow': 'hidden',
|
||||
'white-space': 'nowrap',
|
||||
'text-overflow': 'ellipsis',
|
||||
}}>
|
||||
<Button
|
||||
fluid
|
||||
style={{
|
||||
'overflow': 'hidden',
|
||||
'white-space': 'nowrap',
|
||||
'text-overflow': 'ellipsis',
|
||||
}}
|
||||
content={outfit.name}
|
||||
onClick={() => act("edit", { outfit: outfit.ref })} />
|
||||
</Stack.Item>
|
||||
<Stack.Item ml={0.5}>
|
||||
<Button
|
||||
icon="save"
|
||||
tooltip="Save this outfit to a file"
|
||||
tooltipPosition="left"
|
||||
onClick={() => act("save", { outfit: outfit.ref })} />
|
||||
</Stack.Item>
|
||||
<Stack.Item ml={0.5}>
|
||||
<Button
|
||||
color="bad"
|
||||
icon="trash-alt"
|
||||
tooltip="Delete this outfit"
|
||||
tooltipPosition="left"
|
||||
onClick={() => act("delete", { outfit: outfit.ref })} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Stack.Item>
|
||||
))}
|
||||
</Stack>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { filter, map, sortBy, uniq } from 'common/collections';
|
||||
import { flow } from 'common/fp';
|
||||
import { createSearch } from 'common/string';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { Box, Button, Icon, Input, Section, Stack, Tabs } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
// here's an important mental define:
|
||||
// custom outfits give a ref keyword instead of path
|
||||
const getOutfitKey = outfit => outfit.path || outfit.ref;
|
||||
|
||||
const useOutfitTabs = (context, categories) => {
|
||||
return useLocalState(context, 'selected-tab', categories[0]);
|
||||
};
|
||||
|
||||
export const SelectEquipment = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const {
|
||||
name,
|
||||
icon64,
|
||||
current_outfit,
|
||||
favorites,
|
||||
} = data;
|
||||
|
||||
const isFavorited = entry => favorites?.includes(entry.path);
|
||||
|
||||
const outfits = map(entry => ({
|
||||
...entry,
|
||||
favorite: isFavorited(entry),
|
||||
}))([
|
||||
...data.outfits,
|
||||
...data.custom_outfits,
|
||||
]);
|
||||
|
||||
// even if no custom outfits were sent, we still want to make sure there's
|
||||
// at least a 'Custom' tab so the button to create a new one pops up
|
||||
const categories = uniq([
|
||||
...outfits.map(entry => entry.category),
|
||||
'Custom',
|
||||
]);
|
||||
const [tab] = useOutfitTabs(context, categories);
|
||||
|
||||
const [searchText, setSearchText] = useLocalState(
|
||||
context, 'searchText', '');
|
||||
const searchFilter = createSearch(searchText, entry => (
|
||||
entry.name + entry.path
|
||||
));
|
||||
|
||||
const visibleOutfits = flow([
|
||||
filter(entry => entry.category === tab),
|
||||
filter(searchFilter),
|
||||
sortBy(
|
||||
entry => !entry.favorite,
|
||||
entry => !entry.priority,
|
||||
entry => entry.name
|
||||
),
|
||||
])(outfits);
|
||||
|
||||
const getOutfitEntry = current_outfit => outfits.find(outfit => (
|
||||
getOutfitKey(outfit) === current_outfit
|
||||
));
|
||||
|
||||
const currentOutfitEntry = getOutfitEntry(current_outfit);
|
||||
|
||||
return (
|
||||
<Window
|
||||
width={650}
|
||||
height={415}>
|
||||
<Window.Content>
|
||||
<Stack fill>
|
||||
<Stack.Item>
|
||||
<Stack fill vertical>
|
||||
<Stack.Item>
|
||||
<Input
|
||||
fluid
|
||||
autoFocus
|
||||
placeholder="Search"
|
||||
value={searchText}
|
||||
onInput={(e, value) => setSearchText(value)} />
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<DisplayTabs categories={categories} />
|
||||
</Stack.Item>
|
||||
<Stack.Item mt={0} grow={1} basis={0}>
|
||||
<OutfitDisplay
|
||||
entries={visibleOutfits}
|
||||
currentTab={tab} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow={1} basis={0}>
|
||||
<Stack fill vertical>
|
||||
<Stack.Item>
|
||||
<Section>
|
||||
<CurrentlySelectedDisplay entry={currentOutfitEntry} />
|
||||
</Section>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow={1}>
|
||||
<Section
|
||||
fill
|
||||
title={name}
|
||||
textAlign="center">
|
||||
<Box
|
||||
as="img"
|
||||
m={0}
|
||||
src={`data:image/jpeg;base64,${icon64}`}
|
||||
height="100%"
|
||||
style={{
|
||||
'-ms-interpolation-mode': 'nearest-neighbor',
|
||||
}} />
|
||||
</Section>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
const DisplayTabs = (props, context) => {
|
||||
const { categories } = props;
|
||||
const [tab, setTab] = useOutfitTabs(context, categories);
|
||||
return (
|
||||
<Tabs textAlign="center">
|
||||
{categories.map(category => (
|
||||
<Tabs.Tab
|
||||
key={category}
|
||||
selected={tab === category}
|
||||
onClick={() => setTab(category)}>
|
||||
{category}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
const OutfitDisplay = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { current_outfit } = data;
|
||||
const { entries, currentTab } = props;
|
||||
return (
|
||||
<Section fill scrollable>
|
||||
{entries.map(entry => (
|
||||
<Button
|
||||
key={getOutfitKey(entry)}
|
||||
fluid
|
||||
ellipsis
|
||||
icon={entry.favorite && 'star'}
|
||||
iconColor="gold"
|
||||
content={entry.name}
|
||||
title={entry.path || entry.name}
|
||||
selected={getOutfitKey(entry) === current_outfit}
|
||||
onClick={() => act('preview', { path: getOutfitKey(entry) })} />
|
||||
))}
|
||||
{currentTab === "Custom" && (
|
||||
<Button
|
||||
color="transparent"
|
||||
icon="plus"
|
||||
fluid
|
||||
onClick={() => act('customoutfit')}>
|
||||
Create a custom outfit...
|
||||
</Button>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const CurrentlySelectedDisplay = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { current_outfit } = data;
|
||||
const { entry } = props;
|
||||
return (
|
||||
<Stack align="center">
|
||||
{entry?.path && (
|
||||
<Stack.Item>
|
||||
<Icon
|
||||
size={1.6}
|
||||
name={entry.favorite ? 'star' : 'star-o'}
|
||||
color="gold"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => act('togglefavorite', {
|
||||
path: entry.path,
|
||||
})} />
|
||||
</Stack.Item>
|
||||
)}
|
||||
<Stack.Item grow={1} basis={0}>
|
||||
<Box color="label">
|
||||
Currently selected:
|
||||
</Box>
|
||||
<Box
|
||||
title={entry?.path}
|
||||
style={{
|
||||
'overflow': 'hidden',
|
||||
'white-space': 'nowrap',
|
||||
'text-overflow': 'ellipsis',
|
||||
}}>
|
||||
{entry?.name}
|
||||
</Box>
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<Button
|
||||
mr={0.8}
|
||||
lineHeight={2}
|
||||
color="green"
|
||||
onClick={() => act('applyoutfit', {
|
||||
path: current_outfit,
|
||||
})}>
|
||||
Confirm
|
||||
</Button>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user