mirror of
1
Fork 0

refactor custom-emoji, progress on federation bulk

This commit is contained in:
f0x 2023-01-12 23:33:39 +00:00
parent 59413c3482
commit 9b6a54032c
28 changed files with 969 additions and 430 deletions

View File

@ -0,0 +1,282 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");
const { Switch, Route, Link, Redirect, useRoute, useLocation } = require("wouter");
const fileDownload = require("js-file-download");
const { formFields } = require("../components/form-fields");
const api = require("../lib/api");
const adminActions = require("../redux/reducers/admin").actions;
const submit = require("../lib/submit");
const BackButton = require("../components/back-button");
const Loading = require("../components/loading");
const { matchSorter } = require("match-sorter");
const base = "/settings/admin/federation";
module.exports = function AdminSettings() {
const dispatch = Redux.useDispatch();
const loadedBlockedInstances = Redux.useSelector(state => state.admin.loadedBlockedInstances);
React.useEffect(() => {
if (!loadedBlockedInstances) {
Promise.try(() => {
return dispatch(api.admin.fetchDomainBlocks());
});
}
}, [dispatch, loadedBlockedInstances]);
if (!loadedBlockedInstances) {
return (
<div>
<h1>Federation</h1>
<div>
<Loading />
</div>
</div>
);
}
return (
<Switch>
<Route path={`${base}/:domain`}>
<InstancePageWrapped />
</Route>
<InstanceOverview />
</Switch>
);
};
function InstanceOverview() {
const [filter, setFilter] = React.useState("");
const blockedInstances = Redux.useSelector(state => state.admin.blockedInstances);
const [_location, setLocation] = useLocation();
const filteredInstances = React.useMemo(() => {
return matchSorter(Object.values(blockedInstances), filter, { keys: ["domain"] });
}, [blockedInstances, filter]);
function filterFormSubmit(e) {
e.preventDefault();
setLocation(`${base}/${filter}`);
}
return (
<>
<h1>Federation</h1>
Here you can see an overview of blocked instances.
<div className="instance-list">
<h2>Blocked instances</h2>
<form action={`${base}/view`} className="filter" role="search" onSubmit={filterFormSubmit}>
<input name="domain" value={filter} onChange={(e) => setFilter(e.target.value)} />
<Link to={`${base}/${filter}`}><a className="button">Add block</a></Link>
</form>
<div className="list scrolling">
{filteredInstances.map((entry) => {
return (
<Link key={entry.domain} to={`${base}/${entry.domain}`}>
<a className="entry nounderline">
<span id="domain">
{entry.domain}
</span>
<span id="date">
{new Date(entry.created_at).toLocaleString()}
</span>
</a>
</Link>
);
})}
</div>
</div>
<BulkBlocking />
</>
);
}
const Bulk = formFields(adminActions.updateBulkBlockVal, (state) => state.admin.bulkBlock);
function BulkBlocking() {
const dispatch = Redux.useDispatch();
const { bulkBlock, blockedInstances } = Redux.useSelector(state => state.admin);
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
function importBlocks() {
setStatus("Processing");
setError("");
return Promise.try(() => {
return dispatch(api.admin.bulkDomainBlock());
}).then(({ success, invalidDomains }) => {
return Promise.try(() => {
return resetBulk();
}).then(() => {
dispatch(adminActions.updateBulkBlockVal(["list", invalidDomains.join("\n")]));
let stat = "";
if (success == 0) {
return setError("No valid domains in import");
} else if (success == 1) {
stat = "Imported 1 domain";
} else {
stat = `Imported ${success} domains`;
}
if (invalidDomains.length > 0) {
if (invalidDomains.length == 1) {
stat += ", input contained 1 invalid domain.";
} else {
stat += `, input contained ${invalidDomains.length} invalid domains.`;
}
} else {
stat += "!";
}
setStatus(stat);
});
}).catch((e) => {
setError(e.message);
setStatus("");
});
}
function exportBlocks() {
return Promise.try(() => {
setStatus("Exporting");
setError("");
let asJSON = bulkBlock.exportType.startsWith("json");
let _asCSV = bulkBlock.exportType.startsWith("csv");
let exportList = Object.values(blockedInstances).map((entry) => {
if (asJSON) {
return {
domain: entry.domain,
public_comment: entry.public_comment
};
} else {
return entry.domain;
}
});
if (bulkBlock.exportType == "json") {
return dispatch(adminActions.updateBulkBlockVal(["list", JSON.stringify(exportList)]));
} else if (bulkBlock.exportType == "json-download") {
return fileDownload(JSON.stringify(exportList), "block-export.json");
} else if (bulkBlock.exportType == "plain") {
return dispatch(adminActions.updateBulkBlockVal(["list", exportList.join("\n")]));
}
}).then(() => {
setStatus("Exported!");
}).catch((e) => {
setError(e.message);
setStatus("");
});
}
function resetBulk(e) {
if (e != undefined) {
e.preventDefault();
}
return dispatch(adminActions.resetBulkBlockVal());
}
function disableInfoFields(props = {}) {
if (bulkBlock.list[0] == "[") {
return {
...props,
disabled: true,
placeHolder: "Domain list is a JSON import, input disabled"
};
} else {
return props;
}
}
return (
<div className="bulk">
<h2>Import / Export <a onClick={resetBulk}>reset</a></h2>
<Bulk.TextArea
id="list"
name="Domains, one per line"
placeHolder={`google.com\nfacebook.com`}
/>
<Bulk.TextArea
id="public_comment"
name="Public comment"
inputProps={disableInfoFields({ rows: 3 })}
/>
<Bulk.TextArea
id="private_comment"
name="Private comment"
inputProps={disableInfoFields({ rows: 3 })}
/>
<Bulk.Checkbox
id="obfuscate"
name="Obfuscate domains? "
inputProps={disableInfoFields()}
/>
<div className="hidden">
<Bulk.File
id="json"
fileType="application/json"
withPreview={false}
/>
</div>
<div className="messagebutton">
<div>
<button type="submit" onClick={importBlocks}>Import</button>
</div>
<div>
<button type="submit" onClick={exportBlocks}>Export</button>
<Bulk.Select id="exportType" name="Export type" options={
<>
<option value="plain">One per line in text field</option>
<option value="json">JSON in text field</option>
<option value="json-download">JSON file download</option>
<option disabled value="csv">CSV in text field (glitch-soc)</option>
<option disabled value="csv-download">CSV file download (glitch-soc)</option>
</>
} />
</div>
<br />
<div>
{errorMsg.length > 0 &&
<div className="error accent">{errorMsg}</div>
}
{statusMsg.length > 0 &&
<div className="accent">{statusMsg}</div>
}
</div>
</div>
</div>
);
}

View File

@ -36,7 +36,9 @@ function useEmojiByCategory(emoji) {
), [emoji]);
}
function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
function CategorySelect({ field, children }) {
const { value, setIsNew } = field;
const {
data: emoji = [],
isLoading,
@ -67,12 +69,12 @@ function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
if (value != undefined && isSuccess && value.trim().length > 0) {
setIsNew(!categories.has(value.trim()));
}
}, [categories, value, setIsNew, isSuccess]);
}, [categories, value, isSuccess, setIsNew]);
if (error) { // fall back to plain text input, but this would almost certainly have caused a bigger error message elsewhere
return (
<>
<input type="text" placeholder="e.g., reactions" onChange={(e) => {categoryState.value = e.target.value;}}/>;
<input type="text" placeholder="e.g., reactions" onChange={(e) => { field.value = e.target.value; }} />;
</>
);
} else if (isLoading) {
@ -81,10 +83,10 @@ function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
return (
<ComboBox
state={categoryState}
field={field}
items={categoryItems}
label="Category"
placeHolder="e.g., reactions"
placeholder="e.g., reactions"
children={children}
/>
);

View File

@ -19,15 +19,21 @@
"use strict";
const React = require("react");
const { useRoute, Link, Redirect } = require("wouter");
const { CategorySelect } = require("../category-select");
const { useComboBoxInput, useFileInput } = require("../../../lib/form");
const query = require("../../../lib/query");
const { useComboBoxInput, useFileInput, useValue } = require("../../../lib/form");
const { CategorySelect } = require("../category-select");
const useFormSubmit = require("../../../lib/form/submit");
const FakeToot = require("../../../components/fake-toot");
const FormWithData = require("../../../lib/form/form-with-data");
const Loading = require("../../../components/loading");
const { FileInput } = require("../../../components/form/inputs");
const MutationButton = require("../../../components/form/mutation-button");
const { Error } = require("../../../components/error");
const base = "/settings/custom-emoji/local";
@ -39,57 +45,41 @@ module.exports = function EmojiDetailRoute() {
return (
<div className="emoji-detail">
<Link to={base}><a>&lt; go back</a></Link>
<EmojiDetailData emojiId={params.emojiId} />
<FormWithData dataQuery={query.useGetEmojiQuery} arg={params.emojiId} DataForm={EmojiDetailForm} />
</div>
);
}
};
function EmojiDetailData({ emojiId }) {
const { currentData: emoji, isLoading, error } = query.useGetEmojiQuery(emojiId);
if (error) {
return (
<div className="error accent">
{error.status}: {error.data.error}
</div>
);
} else if (isLoading) {
return (
<div>
<Loading />
</div>
);
} else {
return <EmojiDetail emoji={emoji} />;
}
}
function EmojiDetail({ emoji }) {
const [modifyEmoji, modifyResult] = query.useEditEmojiMutation();
const [isNewCategory, setIsNewCategory] = React.useState(false);
const [categoryState, _resetCategory, { category }] = useComboBoxInput("category", { defaultValue: emoji.category });
const [onFileChange, _resetFile, { image, imageURL, imageInfo }] = useFileInput("image", {
function EmojiDetailForm({ data: emoji }) {
const form = {
id: useValue("id", emoji.id),
category: useComboBoxInput("category", { defaultValue: emoji.category }),
image: useFileInput("image", {
withPreview: true,
maxSize: 50 * 1024
});
maxSize: 50 * 1024 // TODO: get from instance api
})
};
function modifyCategory() {
modifyEmoji({ id: emoji.id, category: category.trim() });
}
function modifyImage() {
modifyEmoji({ id: emoji.id, image: image });
}
const [modifyEmoji, result] = useFormSubmit(form, query.useEditEmojiMutation());
// Automatic submitting of category change
React.useEffect(() => {
if (category != emoji.category && !categoryState.open && !isNewCategory && category?.trim().length > 0) {
modifyEmoji({ id: emoji.id, category: category.trim() });
if (
form.category.hasChanged() &&
!form.category.state.open &&
!form.category.isNew) {
modifyEmoji();
}
}, [isNewCategory, category, categoryState.open, emoji.category, emoji.id, modifyEmoji]);
}, [form.category.hasChanged(), form.category.isNew, form.category.state.open]);
const [deleteEmoji, deleteResult] = query.useDeleteEmojiMutation();
if (deleteResult.isSuccess) {
return <Redirect to={base} />;
}
console.log(form.category);
return (
<>
@ -97,58 +87,62 @@ function EmojiDetail({ emoji }) {
<img src={emoji.url} alt={emoji.shortcode} title={emoji.shortcode} />
<div>
<h2>{emoji.shortcode}</h2>
<DeleteButton id={emoji.id} />
<MutationButton
label="Delete"
type="button"
onClick={() => deleteEmoji(emoji.id)}
className="danger button-inline"
showError={false}
result={deleteResult}
/>
</div>
</div>
<div className="left-border">
<h2>Modify this emoji {modifyResult.isLoading && "(processing..)"}</h2>
{modifyResult.error && <div className="error">
{modifyResult.error.status}: {modifyResult.error.data.error}
</div>}
<form onSubmit={modifyEmoji} className="left-border">
<h2>Modify this emoji {result.isLoading && <Loading />}</h2>
<div className="update-category">
<CategorySelect
value={category}
categoryState={categoryState}
setIsNew={setIsNewCategory}
field={form.category}
>
<button style={{ visibility: (isNewCategory ? "initial" : "hidden") }} onClick={modifyCategory}>
Create
</button>
<MutationButton
name="create-category"
label="Create"
result={result}
showError={false}
style={{ visibility: (form.category.isNew ? "initial" : "hidden") }}
/>
</CategorySelect>
</div>
<div className="update-image">
<b>Image</b>
<div className="form-field file">
<label className="file-input button" htmlFor="image">
Browse
</label>
{imageInfo}
<input
className="hidden"
type="file"
id="image"
name="Image"
<FileInput
field={form.image}
label="Image"
accept="image/png,image/gif"
onChange={onFileChange}
/>
</div>
<button onClick={modifyImage} disabled={image == undefined}>Replace image</button>
<MutationButton
name="image"
label="Replace image"
showError={false}
className="button-inline"
result={result}
/>
<FakeToot>
Look at this new custom emoji <img
className="emoji"
src={imageURL ?? emoji.url}
src={form.image.previewURL ?? emoji.url}
title={`:${emoji.shortcode}:`}
alt={emoji.shortcode}
/> isn&apos;t it cool?
</FakeToot>
{result.error && <Error error={result.error} />}
{deleteResult.error && <Error error={deleteResult.error} />}
</div>
</div>
</form>
</>
);
}

View File

@ -31,9 +31,9 @@ module.exports = function CustomEmoji() {
<>
<Switch>
<Route path={`${base}/:emojiId`}>
<EmojiDetail />
<EmojiDetail baseUrl={base} />
</Route>
<EmojiOverview />
<EmojiOverview baseUrl={base} />
</Switch>
</>
);

View File

@ -21,98 +21,65 @@
const Promise = require('bluebird');
const React = require("react");
const FakeToot = require("../../../components/fake-toot");
const MutationButton = require("../../../components/form/mutation-button");
const query = require("../../../lib/query");
const {
useTextInput,
useFileInput,
useComboBoxInput
} = require("../../../lib/form");
const useShortcode = require("./use-shortcode");
const useFormSubmit = require("../../../lib/form/submit");
const {
TextInput, FileInput
} = require("../../../components/form/inputs");
const query = require("../../../lib/query");
const { CategorySelect } = require('../category-select');
const FakeToot = require("../../../components/fake-toot");
const MutationButton = require("../../../components/form/mutation-button");
const shortcodeRegex = /^[a-z0-9_]+$/;
module.exports = function NewEmojiForm() {
const shortcode = useShortcode();
module.exports = function NewEmojiForm({ emoji }) {
const emojiCodes = React.useMemo(() => {
return new Set(emoji.map((e) => e.shortcode));
}, [emoji]);
const [addEmoji, result] = query.useAddEmojiMutation();
const [onFileChange, resetFile, { image, imageURL, imageInfo }] = useFileInput("image", {
const image = useFileInput("image", {
withPreview: true,
maxSize: 50 * 1024
maxSize: 50 * 1024 // TODO: get from instance api?
});
const [onShortcodeChange, resetShortcode, { shortcode, setShortcode, shortcodeRef }] = useTextInput("shortcode", {
validator: function validateShortcode(code) {
// technically invalid, but hacky fix to prevent validation error on page load
if (shortcode == "") { return ""; }
const category = useComboBoxInput("category");
if (emojiCodes.has(code)) {
return "Shortcode already in use";
}
const [submitForm, result] = useFormSubmit({
shortcode, image, category
}, query.useAddEmojiMutation());
if (code.length < 2 || code.length > 30) {
return "Shortcode must be between 2 and 30 characters";
}
// const [onFileChange, resetFile, { image, imageURL, imageInfo }] = useFileInput("image", {
// withPreview: true,
// maxSize: 50 * 1024
// });
if (code.toLowerCase() != code) {
return "Shortcode must be lowercase";
}
if (!shortcodeRegex.test(code)) {
return "Shortcode must only contain lowercase letters, numbers, and underscores";
}
return "";
}
});
const [categoryState, resetCategory, { category }] = useComboBoxInput("category");
// const [categoryState, resetCategory, { category }] = useComboBoxInput("category");
React.useEffect(() => {
if (shortcode.length == 0) {
if (image != undefined) {
let [name, _ext] = image.name.split(".");
setShortcode(name);
if (shortcode.value.length == 0) {
if (image.value != undefined) {
let [name, _ext] = image.value.name.split(".");
shortcode.setter(name);
}
}
// we explicitly don't want to add 'shortcode' as a dependency here
// because we only want this to update to the filename if the field is empty
// at the moment the file is selected, not some time after when the field is emptied
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [image]);
}, [image.value]);
function uploadEmoji(e) {
if (e) {
e.preventDefault();
}
let emojiOrShortcode = `:${shortcode.value}:`;
Promise.try(() => {
return addEmoji({
image,
shortcode,
category
});
}).then((res) => {
if (res.error == undefined) {
resetFile();
resetShortcode();
resetCategory();
}
});
}
let emojiOrShortcode = `:${shortcode}:`;
if (imageURL != undefined) {
if (image.previewValue != undefined) {
emojiOrShortcode = <img
className="emoji"
src={imageURL}
src={image.previewValue}
title={`:${shortcode}:`}
alt={shortcode}
/>;
@ -126,39 +93,19 @@ module.exports = function NewEmojiForm({ emoji }) {
Look at this new custom emoji {emojiOrShortcode} isn&apos;t it cool?
</FakeToot>
<form onSubmit={uploadEmoji} className="form-flex">
<div className="form-field file">
<label className="file-input button" htmlFor="image">
Browse
</label>
{imageInfo}
<input
className="hidden"
type="file"
id="image"
name="Image"
<form onSubmit={submitForm} className="form-flex">
<FileInput
field={image}
accept="image/png,image/gif"
onChange={onFileChange}
/>
</div>
<div className="form-field text">
<label htmlFor="shortcode">
Shortcode, must be unique among the instance's local emoji
</label>
<input
type="text"
id="shortcode"
name="Shortcode"
ref={shortcodeRef}
onChange={onShortcodeChange}
value={shortcode}
<TextInput
field={shortcode}
label="Shortcode, must be unique among the instance's local emoji"
/>
</div>
<CategorySelect
value={category}
categoryState={categoryState}
field={category}
/>
<MutationButton label="Upload emoji" result={result} />

View File

@ -27,9 +27,7 @@ const query = require("../../../lib/query");
const { useEmojiByCategory } = require("../category-select");
const Loading = require("../../../components/loading");
const base = "/settings/custom-emoji/local";
module.exports = function EmojiOverview() {
module.exports = function EmojiOverview({ baseUrl }) {
const {
data: emoji = [],
isLoading,
@ -45,7 +43,7 @@ module.exports = function EmojiOverview() {
{isLoading
? <Loading />
: <>
<EmojiList emoji={emoji}/>
<EmojiList emoji={emoji} baseUrl={baseUrl} />
<NewEmojiForm emoji={emoji} />
</>
}
@ -53,7 +51,7 @@ module.exports = function EmojiOverview() {
);
};
function EmojiList({emoji}) {
function EmojiList({ emoji, baseUrl }) {
const emojiByCategory = useEmojiByCategory(emoji);
return (
@ -62,22 +60,21 @@ function EmojiList({emoji}) {
<div className="list emoji-list">
{emoji.length == 0 && "No local emoji yet, add one below"}
{Object.entries(emojiByCategory).map(([category, entries]) => {
return <EmojiCategory key={category} category={category} entries={entries}/>;
return <EmojiCategory key={category} category={category} entries={entries} baseUrl={baseUrl} />;
})}
</div>
</div>
);
}
function EmojiCategory({category, entries}) {
function EmojiCategory({ category, entries, baseUrl }) {
return (
<div className="entry">
<b>{category}</b>
<div className="emoji-group">
{entries.map((e) => {
return (
<Link key={e.id} to={`${base}/${e.id}`}>
{/* <Link key={e.static_url} to={`${base}`}> */}
<Link key={e.id} to={`${baseUrl}/${e.id}`}>
<a>
<img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`} />
</a>

View File

@ -0,0 +1,61 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const React = require("react");
const query = require("../../../lib/query");
const { useTextInput } = require("../../../lib/form");
const shortcodeRegex = /^[a-z0-9_]+$/;
module.exports = function useShortcode() {
const {
data: emoji = []
} = query.useGetAllEmojiQuery({ filter: "domain:local" });
const emojiCodes = React.useMemo(() => {
return new Set(emoji.map((e) => e.shortcode));
}, [emoji]);
return useTextInput("shortcode", {
validator: function validateShortcode(code) {
// technically invalid, but hacky fix to prevent validation error on page load
if (code == "") { return ""; }
if (emojiCodes.has(code)) {
return "Shortcode already in use";
}
if (code.length < 2 || code.length > 30) {
return "Shortcode must be between 2 and 30 characters";
}
if (code.toLowerCase() != code) {
return "Shortcode must be lowercase";
}
if (!shortcodeRegex.test(code)) {
return "Shortcode must only contain lowercase letters, numbers, and underscores";
}
return "";
}
});
};

View File

@ -18,10 +18,9 @@
"use strict";
const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");
const syncpipe = require("syncpipe");
const query = require("../../../lib/query");
const {
useTextInput,
@ -34,44 +33,15 @@ const useFormSubmit = require("../../../lib/form/submit");
const CheckList = require("../../../components/check-list");
const { CategorySelect } = require('../category-select');
const query = require("../../../lib/query");
const Loading = require("../../../components/loading");
const { TextInput } = require("../../../components/form/inputs");
const MutationButton = require("../../../components/form/mutation-button");
const { Error } = require("../../../components/error");
module.exports = function ParseFromToot({ emojiCodes }) {
const [searchStatus, { data, isLoading, isSuccess, error }] = query.useSearchStatusForEmojiMutation();
const instanceDomain = Redux.useSelector((state) => (new URL(state.oauth.instance).host));
const [searchStatus, result] = query.useSearchStatusForEmojiMutation();
const [onURLChange, _resetURL, { url }] = useTextInput("url");
const searchResult = React.useMemo(() => {
if (!isSuccess) {
return null;
}
if (data.type == "none") {
return "No results found";
}
if (data.domain == instanceDomain) {
return <b>This is a local user/toot, all referenced emoji are already on your instance</b>;
}
if (data.list.length == 0) {
return <b>This {data.type == "statuses" ? "toot" : "account"} doesn't use any custom emoji</b>;
}
return (
<CopyEmojiForm
localEmojiCodes={emojiCodes}
type={data.type}
domain={data.domain}
emojiList={data.list}
/>
);
}, [isSuccess, data, instanceDomain, emojiCodes]);
function submitSearch(e) {
e.preventDefault();
if (url.trim().length != 0) {
@ -95,101 +65,143 @@ module.exports = function ParseFromToot({ emojiCodes }) {
onChange={onURLChange}
value={url}
/>
<button className="button-inline" disabled={isLoading}>
<button className="button-inline" disabled={result.isLoading}>
<i className={[
"fa fa-fw",
(isLoading
(result.isLoading
? "fa-refresh fa-spin"
: "fa-search")
].join(" ")} aria-hidden="true" title="Search" />
<span className="sr-only">Search</span>
</button>
</div>
{error && <div className="error">{error.data.error}</div>}
</div>
</form>
{searchResult}
<SearchResult result={result} localEmojiCodes={emojiCodes} />
</div>
);
};
function CopyEmojiForm({ localEmojiCodes, type, domain, emojiList }) {
const [patchRemoteEmojis, patchResult] = query.usePatchRemoteEmojisMutation();
const [err, setError] = React.useState();
function SearchResult({ result, localEmojiCodes }) {
const { error, data, isSuccess, isError } = result;
const emojiCheckList = useCheckListInput("selectedEmoji", {
if (!(isSuccess || isError)) {
return null;
}
if (error == "NONE_FOUND") {
return "No results found";
} else if (error == "LOCAL_INSTANCE") {
return <b>This is a local user/toot, all referenced emoji are already on your instance</b>;
} else if (error != undefined) {
return <Error error={result.error} />;
}
if (data.list.length == 0) {
return <b>This {data.type == "statuses" ? "toot" : "account"} doesn't use any custom emoji</b>;
}
return (
<CopyEmojiForm
localEmojiCodes={localEmojiCodes}
type={data.type}
domain={data.domain}
emojiList={data.list}
/>
);
}
function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
const form = {
selectedEmoji: useCheckListInput("selectedEmoji", {
entries: emojiList,
uniqueKey: "shortcode"
});
}),
category: useComboBoxInput("category")
};
const [categoryState, resetCategory, { category }] = useComboBoxInput("category");
const [formSubmit, result] = useFormSubmit(form, query.usePatchRemoteEmojisMutation(), { changedOnly: false });
console.log("action:", result.action);
const buttonsInactive = emojiCheckList.someSelected
const buttonsInactive = form.selectedEmoji.someSelected
? {}
: {
disabled: true,
title: "No emoji selected, cannot perform any actions"
};
function submit(action) {
Promise.try(() => {
setError(null);
const selectedShortcodes = emojiCheckList.selectedValues.map(([shortcode, entry]) => {
if (action == "copy" && !entry.valid) {
throw `One or more selected emoji have non-unique shortcodes (${shortcode}), unselect them or pick a different local shortcode`;
}
return {
shortcode,
localShortcode: entry.shortcode
};
});
// function submit(action) {
// Promise.try(() => {
// setError(null);
// const selectedShortcodes = emojiCheckList.selectedValues.map(([shortcode, entry]) => {
// if (action == "copy" && !entry.valid) {
// throw `One or more selected emoji have non-unique shortcodes (${shortcode}), unselect them or pick a different local shortcode`;
// }
// return {
// shortcode,
// localShortcode: entry.shortcode
// };
// });
return patchRemoteEmojis({
action,
domain,
list: selectedShortcodes,
category
}).unwrap();
}).then(() => {
emojiCheckList.reset();
resetCategory();
}).catch((e) => {
if (Array.isArray(e)) {
setError(e.map(([shortcode, msg]) => (
<div key={shortcode}>
{shortcode}: <span style={{ fontWeight: "initial" }}>{msg}</span>
</div>
)));
} else {
setError(e);
}
});
}
// return patchRemoteEmojis({
// action,
// domain,
// list: selectedShortcodes,
// category
// }).unwrap();
// }).then(() => {
// emojiCheckList.reset();
// resetCategory();
// }).catch((e) => {
// if (Array.isArray(e)) {
// setError(e.map(([shortcode, msg]) => (
// <div key={shortcode}>
// {shortcode}: <span style={{ fontWeight: "initial" }}>{msg}</span>
// </div>
// )));
// } else {
// setError(e);
// }
// });
// }
return (
<div className="parsed">
<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>
<form onSubmit={formSubmit}>
<CheckList
field={emojiCheckList}
field={form.selectedEmoji}
Component={EmojiEntry}
localEmojiCodes={localEmojiCodes}
/>
<CategorySelect
value={category}
categoryState={categoryState}
field={form.category}
/>
<div className="action-buttons row">
<MutationButton label="Copy to local emoji" type="button" result={patchResult} {...buttonsInactive} />
<MutationButton label="Disable" type="button" result={patchResult} className="button danger" {...buttonsInactive} />
<MutationButton name="copy" label="Copy to local emoji" result={result} showError={false} {...buttonsInactive} />
<MutationButton name="disable" label="Disable" result={result} className="button danger" showError={false} {...buttonsInactive} />
</div>
{err && <div className="error">
{err}
</div>}
{patchResult.isSuccess && <div>
Action applied to {patchResult.data.length} emoji
</div>}
{result.error && (
Array.isArray(result.error)
? <ErrorList errors={result.error} />
: <Error error={result.error} />
)}
</form>
</div>
);
}
function ErrorList({ errors }) {
return (
<div className="error">
One or multiple emoji failed to process:
{errors.map(([shortcode, err]) => (
<div key={shortcode}>
<b>{shortcode}:</b> {err}
</div>
))}
</div>
);
}

View File

@ -21,6 +21,7 @@
const React = require("react");
const query = require("../../lib/query");
const processDomainList = require("../../lib/import-export");
const {
useTextInput,
@ -34,37 +35,71 @@ const {
TextInput,
TextArea,
Checkbox,
FileInput
FileInput,
useCheckListInput
} = require("../../components/form/inputs");
const FormWithData = require("../../lib/form/form-with-data");
const CheckList = require("../../components/check-list");
const MutationButton = require("../../components/form/mutation-button");
module.exports = function ImportExport() {
const [parsedList, setParsedList] = React.useState();
const form = {
domains: useTextInput("domains"),
obfuscate: useBoolInput("obfuscate"),
commentPrivate: useTextInput("private_comment"),
commentPublic: useTextInput("public_comment"),
// json: useFileInput("json")
};
function submitImport(e) {
e.preventDefault();
Promise.try(() => {
return processDomainList(form.domains.value);
}).then((processed) => {
setParsedList(processed);
}).catch((e) => {
console.error(e);
});
}
return (
<div className="import-export">
<h2>Import / Export</h2>
<FormWithData
dataQuery={query.useInstanceBlocksQuery}
DataForm={ImportExportForm}
/>
<div>
{
parsedList
? <ImportExportList list={parsedList} />
: <ImportExportForm form={form} submitImport={submitImport} />
}
</div>
</div>
);
};
function ImportExportForm({ data: blockedInstances }) {
const form = {
list: useTextInput("list"),
obfuscate: useBoolInput("obfuscate"),
commentPrivate: useTextInput("private_comment"),
commentPublic: useTextInput("public_comment"),
json: useFileInput("json")
};
function ImportExportList({ list }) {
const entryCheckList = useCheckListInput("selectedDomains", {
entries: list,
uniqueKey: "domain"
});
return (
<form>
<CheckList
/>
);
}
function ImportExportForm({ form, submitImport }) {
return (
<form onSubmit={submitImport}>
<TextArea
field={form.list}
label="Domains, one per line"
field={form.domains}
label="Domains, one per line (plaintext) or JSON"
placeholder={`google.com\nfacebook.com`}
rows={8}
/>
<TextArea
@ -83,6 +118,10 @@ function ImportExportForm({ data: blockedInstances }) {
field={form.obfuscate}
label="Obfuscate domain in public lists"
/>
<div>
<MutationButton label="Import" result={importResult} /> {/* default form action */}
</div>
</form>
);
}

View File

@ -25,13 +25,14 @@ const baseUrl = `/settings/admin/federation`;
const InstanceOverview = require("./overview");
const InstanceDetail = require("./detail");
const InstanceImportExport = require("./import-export");
module.exports = function Federation({ }) {
return (
<Switch>
{/* <Route path={`${baseUrl}/import-export`}>
<Route path={`${baseUrl}/import-export`}>
<InstanceImportExport />
</Route> */}
</Route>
<Route path={`${baseUrl}/:domain`}>
<InstanceDetail baseUrl={baseUrl} />

View File

@ -72,7 +72,7 @@ module.exports = function InstanceOverview({ baseUrl }) {
<span>
{blockedInstances.length} blocked instance{blockedInstances.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered)`}
</span>
<div className="list">
<div className="list scrolling">
{filteredInstances.map((entry) => {
return (
<Link key={entry.domain} to={`${baseUrl}/${entry.domain}`}>
@ -90,8 +90,7 @@ module.exports = function InstanceOverview({ baseUrl }) {
</div>
</div>
</div>
<ImportExport />
<Link to={`${baseUrl}/import-export`}><a>Or use the bulk import/export interface</a></Link>
</>
);
};

View File

@ -26,21 +26,21 @@ const {
ComboboxPopover,
} = require("ariakit/combobox");
module.exports = function ComboBox({state, items, label, placeHolder, children}) {
module.exports = function ComboBox({ field, items, label, children, ...inputProps }) {
return (
<div className="form-field combobox-wrapper">
<label>
{label}
<div className="row">
<Combobox
state={state}
placeholder={placeHolder}
state={field.state}
className="combobox input"
{...inputProps}
/>
{children}
</div>
</label>
<ComboboxPopover state={state} className="popover">
<ComboboxPopover state={field.state} className="popover">
{items.map(([key, value]) => (
<ComboboxItem className="combobox-item" key={key} value={key}>
{value}

View File

@ -20,7 +20,7 @@
const React = require("react");
module.exports = function ErrorFallback({error, resetErrorBoundary}) {
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div className="error">
<p>
@ -41,4 +41,33 @@ module.exports = function ErrorFallback({error, resetErrorBoundary}) {
</p>
</div>
);
};
}
function Error({ error }) {
console.error("Rendering:", error);
let message;
if (error.data != undefined) { // RTK Query error with data
if (error.status) {
message = (<>
<b>{error.status}:</b> {error.data.error}
</>);
} else {
message = error.data.error;
}
} else if (error.name != undefined || error.type != undefined) { // JS error
message = (<>
<b>{error.type && error.name}:</b> {error.message}
</>);
} else {
message = error.message ?? error;
}
return (
<div className="error">
{message}
</div>
);
}
module.exports = { ErrorFallback, Error };

View File

@ -60,7 +60,7 @@ function FileInput({ label, field, ...inputProps }) {
return (
<div className="form-field file">
<label>
{label}
<div className="label">{label}</div>
<div className="file-input button">Browse</div>
{infoComponent}
{/* <a onClick={removeFile("header")}>remove</a> */}

View File

@ -19,23 +19,30 @@
"use strict";
const React = require("react");
const { Error } = require("../error");
module.exports = function MutationButton({ label, result, disabled, ...inputProps }) {
module.exports = function MutationButton({ label, result, disabled, showError = true, className = "", ...inputProps }) {
let iconClass = "";
const targetsThisButton = result.action == inputProps.name; // can also both be undefined, which is correct
/* FIXME? submitting an unchanged form will never transition to isLoading,
and check icon will stay faded out because the css animation doesn't restart
*/
if (targetsThisButton) {
if (result.isLoading) {
iconClass = "fa-spin fa-refresh";
} else if (result.isSuccess) {
iconClass = "fa-check fadeout";
}
}
return (<div>
{result.error &&
<section className="error">{result.error.status}: {result.error.data.error}</section>
{(showError && targetsThisButton && result.error) &&
<Error error={result.error} />
}
<button type="submit" disabled={result.isLoading || disabled} {...inputProps}>
<i className={`fa fa-fw with-text ${iconClass}`} aria-hidden="true"></i>
{result.isLoading
<button type="submit" className={"with-icon " + className} disabled={result.isLoading || disabled} {...inputProps}>
<i className={`fa fa-fw ${iconClass}`} aria-hidden="true"></i>
{(targetsThisButton && result.isLoading)
? "Processing..."
: label
}

View File

@ -24,11 +24,12 @@ const Redux = require("react-redux");
const { setInstance } = require("../redux/reducers/oauth").actions;
const api = require("../lib/api");
const { Error } = require("./error");
module.exports = function Login({ error }) {
const dispatch = Redux.useDispatch();
const [instanceField, setInstanceField] = React.useState("");
const [ errorMsg, setErrorMsg ] = React.useState();
const [loginError, setLoginError] = React.useState();
const instanceFieldRef = React.useRef("");
React.useEffect(() => {
@ -65,12 +66,7 @@ module.exports = function Login({error}) {
}).then(() => {
return dispatch(api.oauth.authorize()); // will send user off-page
}).catch((e) => {
setErrorMsg(
<>
<b>{e.type}</b>
<span>{e.message}</span>
</>
);
setLoginError(e);
});
}
@ -90,10 +86,8 @@ module.exports = function Login({error}) {
<form onSubmit={(e) => e.preventDefault()}>
<label htmlFor="instance">Instance: </label>
<input value={instanceField} onChange={updateInstanceField} id="instance" />
{errorMsg &&
<div className="error">
{errorMsg}
</div>
{loginError &&
<Error error={loginError} />
}
<button onClick={tryInstance}>Authenticate</button>
</form>

View File

@ -33,6 +33,7 @@ const { AuthenticationError } = require("./lib/errors");
const Login = require("./components/login");
const Loading = require("./components/loading");
const { Error } = require("./components/error");
require("./style.css");
@ -103,12 +104,7 @@ function App() {
let ErrorElement = null;
if (errorMsg != undefined) {
ErrorElement = (
<div className="error">
<b>{errorMsg.type}</b>
<span>{errorMsg.message}</span>
</div>
);
ErrorElement = <Error error={errorMsg} />;
}
const LogoutElement = (

View File

@ -18,9 +18,13 @@
"use strict";
const React = require("react");
const { useComboboxState } = require("ariakit/combobox");
module.exports = function useComboBoxInput({ name, Name }, { defaultValue } = {}) {
const [isNew, setIsNew] = React.useState(false);
const state = useComboboxState({
defaultValue,
gutter: 0,
@ -36,11 +40,17 @@ module.exports = function useComboBoxInput({ name, Name }, { defaultValue } = {}
reset,
{
[name]: state.value,
name
name,
[`${name}IsNew`]: isNew,
[`set${Name}IsNew`]: setIsNew
}
], {
name,
state,
value: state.value,
hasChanged: () => state.value != defaultValue,
isNew,
setIsNew,
reset
});
};

View File

@ -6,11 +6,15 @@ const Loading = require("../../components/loading");
// Wrap Form component inside component that fires the RTK Query call,
// so Form will only be rendered when data is available to generate form-fields for
module.exports = function FormWithData({dataQuery, DataForm}) {
const {data, isLoading} = dataQuery();
module.exports = function FormWithData({ dataQuery, DataForm, arg }) {
const { data, isLoading } = dataQuery(arg);
if (isLoading) {
return <Loading/>;
return (
<div>
<Loading />
</div>
);
} else {
return <DataForm data={data} />;
}

View File

@ -33,6 +33,13 @@ module.exports = {
useTextInput: makeHook(require("./text")),
useFileInput: makeHook(require("./file")),
useBoolInput: makeHook(require("./bool")),
useComboBoxInput: makeHook(require("./combobox")),
useCheckListInput: makeHook(require("./check-list"))
useComboBoxInput: makeHook(require("./combo-box")),
useCheckListInput: makeHook(require("./check-list")),
useValue: function (name, value) {
return {
name,
value,
hasChanged: () => true // always included
};
}
};

View File

@ -18,31 +18,57 @@
"use strict";
const Promise = require("bluebird");
const React = require("react");
const syncpipe = require("syncpipe");
module.exports = function useFormSubmit(form, [mutationQuery, result], { changedOnly = true } = {}) {
const [usedAction, setUsedAction] = React.useState();
return [
function submitForm(e) {
let action;
if (e?.preventDefault) {
e.preventDefault();
action = e.nativeEvent.submitter.name;
} else {
action = e;
}
setUsedAction(action);
// transform the field definitions into an object with just their values
let updatedFields = [];
const mutationData = syncpipe(form, [
(_) => Object.values(_),
(_) => _.map((field) => {
if (!changedOnly || field.hasChanged()) {
if (field.selectedValues != undefined) {
let selected = field.selectedValues();
if (!changedOnly || selected.length > 0) {
return [field.name, selected];
}
} else if (!changedOnly || field.hasChanged()) {
updatedFields.push(field);
return [field.name, field.value];
} else {
return null;
}
return null;
}),
(_) => _.filter((value) => value != null),
(_) => Object.fromEntries(_)
]);
mutationData.action = action;
return Promise.try(() => {
return mutationQuery(mutationData);
}).then((res) => {
if (res.error == undefined) {
updatedFields.forEach((field) => {
field.reset();
});
}
});
},
result
{
...result,
action: usedAction
}
];
};

View File

@ -22,7 +22,7 @@ const React = require("react");
const { Link, Route, Redirect } = require("wouter");
const { ErrorBoundary } = require("react-error-boundary");
const ErrorFallback = require("../components/error");
const { ErrorFallback } = require("../components/error");
const NavButton = require("../components/nav-button");
function urlSafe(str) {

View File

@ -0,0 +1,65 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const Promise = require("bluebird");
const isValidDomain = require("is-valid-domain");
function parseDomainList(list) {
if (list[0] == "[") {
return JSON.parse(list);
} else {
return list.split("\n").map((line) => {
let trimmed = line.trim();
return trimmed.length > 0
? { domain: trimmed }
: null;
}).filter((a) => a); // not `null`
}
}
function validateDomainList(list) {
list.forEach((entry) => {
entry.valid = isValidDomain(entry.domain, { wildcard: true, allowUnicode: true });
});
return list;
}
function deduplicateDomainList(list) {
let domains = new Set();
return list.filter((entry) => {
if (domains.has(entry.domain)) {
return false;
} else {
domains.add(entry.domain);
return true;
}
});
}
module.exports = function processDomainList(data) {
return Promise.try(() => {
return parseDomainList(data);
}).then((parsed) => {
return deduplicateDomainList(parsed);
}).then((deduped) => {
return validateDomainList(deduped);
});
};

View File

@ -0,0 +1,24 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
module.exports = (build) => ({
// importInstanceBlocks: build.mutation({
// })
});

View File

@ -22,8 +22,8 @@ const {
replaceCacheOnMutation,
appendCacheOnMutation,
spliceCacheOnMutation
} = require("./lib");
const base = require("./base");
} = require("../lib");
const base = require("../base");
const endpoints = (build) => ({
updateInstance: build.mutation({
@ -70,7 +70,8 @@ const endpoints = (build) => ({
return draft.findIndex((block) => block.id == newData.id);
}
})
})
}),
...require("./federation-bulk")(build)
});
module.exports = base.injectEndpoints({ endpoints });

View File

@ -37,12 +37,14 @@ const endpoints = (build) => ({
? [...res.map((emoji) => ({ type: "Emojis", id: emoji.id })), { type: "Emojis", id: "LIST" }]
: [{ type: "Emojis", id: "LIST" }]
}),
getEmoji: build.query({
query: (id) => ({
url: `/api/v1/admin/custom_emojis/${id}`
}),
providesTags: (res, error, id) => [{ type: "Emojis", id }]
}),
addEmoji: build.mutation({
query: (form) => {
return {
@ -58,6 +60,7 @@ const endpoints = (build) => ({
? [{ type: "Emojis", id: "LIST" }, { type: "Emojis", id: res.id }]
: [{ type: "Emojis", id: "LIST" }]
}),
editEmoji: build.mutation({
query: ({ id, ...patch }) => {
return {
@ -75,6 +78,7 @@ const endpoints = (build) => ({
? [{ type: "Emojis", id: "LIST" }, { type: "Emojis", id: res.id }]
: [{ type: "Emojis", id: "LIST" }]
}),
deleteEmoji: build.mutation({
query: (id) => ({
method: "DELETE",
@ -82,75 +86,73 @@ const endpoints = (build) => ({
}),
invalidatesTags: (res, error, id) => [{ type: "Emojis", id }]
}),
searchStatusForEmoji: build.mutation({
query: (url) => ({
method: "GET",
url: `/api/v2/search?q=${encodeURIComponent(url)}&resolve=true&limit=1`
}),
transformResponse: (res) => {
/* Parses search response, prioritizing a toot result,
and returns referenced custom emoji
*/
let type;
if (res.statuses.length > 0) {
type = "statuses";
} else if (res.accounts.length > 0) {
type = "accounts";
} else {
return {
type: "none"
};
}
let data = res[type][0];
return {
type,
domain: (new URL(data.url)).host, // to get WEB_DOMAIN, see https://github.com/superseriousbusiness/gotosocial/issues/1225
list: data.emojis
};
}
}),
patchRemoteEmojis: build.mutation({
queryFn: ({ action, domain, list, category }, api, _extraOpts, baseQuery) => {
const data = [];
const errors = [];
return Promise.each(list, (emoji) => {
queryFn: (url, api, _extraOpts, baseQuery) => {
return Promise.try(() => {
return baseQuery({
method: "GET",
url: `/api/v2/search?q=${encodeURIComponent(url)}&resolve=true&limit=1`
}).then(unwrapRes);
}).then((searchRes) => {
return emojiFromSearchResult(searchRes);
}).then(({ type, domain, list }) => {
const state = api.getState();
if (domain == new URL(state.oauth.instance).host) {
throw "LOCAL_INSTANCE";
}
// search for every mentioned emoji with the admin api to get their ID
return Promise.map(list, (emoji) => {
return baseQuery({
url: `/api/v1/admin/custom_emojis`,
params: {
filter: `domain:${domain},shortcode:${emoji.shortcode}`,
limit: 1
}
}).then(unwrapRes);
}).then(([lookup]) => {
if (lookup == undefined) { throw "not found"; }
}).then((unwrapRes)).then((list) => list[0]);
}, { concurrency: 5 }).then((listWithIDs) => {
return {
data: {
type,
domain,
list: listWithIDs
}
};
});
}).catch((e) => {
return { error: e };
});
}
}),
patchRemoteEmojis: build.mutation({
queryFn: ({ action, ...formData }, _api, _extraOpts, baseQuery) => {
const data = [];
const errors = [];
return Promise.each(formData.selectedEmoji, (emoji) => {
return Promise.try(() => {
let body = {
type: action
};
if (action == "copy") {
body.shortcode = emoji.localShortcode ?? emoji.shortcode;
if (category.trim().length != 0) {
body.category = category;
body.shortcode = emoji.shortcode;
if (formData.category.trim().length != 0) {
body.category = formData.category;
}
}
return baseQuery({
method: "PATCH",
url: `/api/v1/admin/custom_emojis/${lookup.id}`,
url: `/api/v1/admin/custom_emojis/${emoji.id}`,
asForm: true,
body: body
}).then(unwrapRes);
}).then((res) => {
data.push([emoji.shortcode, res]);
}).catch((e) => {
console.error("emoji lookup for", emoji.shortcode, "failed:", e);
console.error("emoji", action, "for", emoji.shortcode, "failed:", e);
let msg = e.message ?? e;
if (e.data.error) {
msg = e.data.error;
@ -171,4 +173,27 @@ const endpoints = (build) => ({
})
});
function emojiFromSearchResult(searchRes) {
/* Parses the search response, prioritizing a toot result,
and returns referenced custom emoji
*/
let type;
if (searchRes.statuses.length > 0) {
type = "statuses";
} else if (searchRes.accounts.length > 0) {
type = "accounts";
} else {
throw "NONE_FOUND";
}
let data = searchRes[type][0];
return {
type,
domain: (new URL(data.url)).host, // to get WEB_DOMAIN, see https://github.com/superseriousbusiness/gotosocial/issues/1225
list: data.emojis
};
}
module.exports = base.injectEndpoints({ endpoints });

View File

@ -16,6 +16,8 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
$fa-fw: 1.28571429em; /* Fork-Awesome 'fa-fw' fixed icon width */
body {
grid-template-rows: auto 1fr;
}
@ -225,6 +227,7 @@ section.with-sidebar > div, section.with-sidebar > form {
.button-inline {
width: auto;
align-self: flex-start;
}
input[type=checkbox] {
@ -344,8 +347,12 @@ form {
}
.form-field.file label {
width: 100%;
display: flex;
display: grid;
grid-template-columns: auto 1fr;
.label {
grid-column: 1 / span 2;
}
}
span.form-info {
@ -598,6 +605,12 @@ span.form-info {
align-items: center;
gap: 0.5rem;
div {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
img {
height: 8.5rem;
width: 8.5rem;
@ -608,15 +621,13 @@ span.form-info {
}
.update-category {
margin-bottom: 1rem;
.combobox-wrapper button {
font-size: 1rem;
margin: 0.15rem 0;
}
.row {
margin-top: 0.4rem;
gap: 0.5rem;
margin-top: 0.1rem;
}
}
@ -681,8 +692,14 @@ span.form-info {
}
}
button .fa.with-text {
margin-left: -1.28571429em;
button.with-icon {
display: flex;
align-content: center;
padding-right: calc(0.5rem + $fa-fw);
.fa {
align-self: center;
}
}
.fadeout {