refactor custom-emoji, progress on federation bulk
This commit is contained in:
parent
59413c3482
commit
9b6a54032c
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -36,13 +36,15 @@ function useEmojiByCategory(emoji) {
|
||||||
), [emoji]);
|
), [emoji]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
|
function CategorySelect({ field, children }) {
|
||||||
|
const { value, setIsNew } = field;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: emoji = [],
|
data: emoji = [],
|
||||||
isLoading,
|
isLoading,
|
||||||
isSuccess,
|
isSuccess,
|
||||||
error
|
error
|
||||||
} = query.useGetAllEmojiQuery({filter: "domain:local"});
|
} = query.useGetAllEmojiQuery({ filter: "domain:local" });
|
||||||
|
|
||||||
const emojiByCategory = useEmojiByCategory(emoji);
|
const emojiByCategory = useEmojiByCategory(emoji);
|
||||||
|
|
||||||
|
@ -52,7 +54,7 @@ function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
|
||||||
const categoryItems = React.useMemo(() => {
|
const categoryItems = React.useMemo(() => {
|
||||||
return syncpipe(emojiByCategory, [
|
return syncpipe(emojiByCategory, [
|
||||||
(_) => Object.keys(_), // just emoji category names
|
(_) => Object.keys(_), // just emoji category names
|
||||||
(_) => matchSorter(_, value, {threshold: matchSorter.rankings.NO_MATCH}), // sorted by complex algorithm
|
(_) => matchSorter(_, value, { threshold: matchSorter.rankings.NO_MATCH }), // sorted by complex algorithm
|
||||||
(_) => _.map((categoryName) => [ // map to input value, and selectable element with icon
|
(_) => _.map((categoryName) => [ // map to input value, and selectable element with icon
|
||||||
categoryName,
|
categoryName,
|
||||||
<>
|
<>
|
||||||
|
@ -67,24 +69,24 @@ function CategorySelect({value, categoryState, setIsNew=() => {}, children}) {
|
||||||
if (value != undefined && isSuccess && value.trim().length > 0) {
|
if (value != undefined && isSuccess && value.trim().length > 0) {
|
||||||
setIsNew(!categories.has(value.trim()));
|
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
|
if (error) { // fall back to plain text input, but this would almost certainly have caused a bigger error message elsewhere
|
||||||
return (
|
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) {
|
} else if (isLoading) {
|
||||||
return <input type="text" value="Loading categories..." disabled={true}/>;
|
return <input type="text" value="Loading categories..." disabled={true} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComboBox
|
<ComboBox
|
||||||
state={categoryState}
|
field={field}
|
||||||
items={categoryItems}
|
items={categoryItems}
|
||||||
label="Category"
|
label="Category"
|
||||||
placeHolder="e.g., reactions"
|
placeholder="e.g., reactions"
|
||||||
children={children}
|
children={children}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -19,15 +19,21 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const React = require("react");
|
const React = require("react");
|
||||||
|
|
||||||
const { useRoute, Link, Redirect } = require("wouter");
|
const { useRoute, Link, Redirect } = require("wouter");
|
||||||
|
|
||||||
const { CategorySelect } = require("../category-select");
|
|
||||||
const { useComboBoxInput, useFileInput } = require("../../../lib/form");
|
|
||||||
|
|
||||||
const query = require("../../../lib/query");
|
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 FakeToot = require("../../../components/fake-toot");
|
||||||
|
const FormWithData = require("../../../lib/form/form-with-data");
|
||||||
const Loading = require("../../../components/loading");
|
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";
|
const base = "/settings/custom-emoji/local";
|
||||||
|
|
||||||
|
@ -39,57 +45,41 @@ module.exports = function EmojiDetailRoute() {
|
||||||
return (
|
return (
|
||||||
<div className="emoji-detail">
|
<div className="emoji-detail">
|
||||||
<Link to={base}><a>< go back</a></Link>
|
<Link to={base}><a>< go back</a></Link>
|
||||||
<EmojiDetailData emojiId={params.emojiId} />
|
<FormWithData dataQuery={query.useGetEmojiQuery} arg={params.emojiId} DataForm={EmojiDetailForm} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function EmojiDetailData({ emojiId }) {
|
function EmojiDetailForm({ data: emoji }) {
|
||||||
const { currentData: emoji, isLoading, error } = query.useGetEmojiQuery(emojiId);
|
const form = {
|
||||||
|
id: useValue("id", emoji.id),
|
||||||
if (error) {
|
category: useComboBoxInput("category", { defaultValue: emoji.category }),
|
||||||
return (
|
image: useFileInput("image", {
|
||||||
<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", {
|
|
||||||
withPreview: true,
|
withPreview: true,
|
||||||
maxSize: 50 * 1024
|
maxSize: 50 * 1024 // TODO: get from instance api
|
||||||
});
|
})
|
||||||
|
};
|
||||||
|
|
||||||
function modifyCategory() {
|
const [modifyEmoji, result] = useFormSubmit(form, query.useEditEmojiMutation());
|
||||||
modifyEmoji({ id: emoji.id, category: category.trim() });
|
|
||||||
}
|
|
||||||
|
|
||||||
function modifyImage() {
|
|
||||||
modifyEmoji({ id: emoji.id, image: image });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Automatic submitting of category change
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (category != emoji.category && !categoryState.open && !isNewCategory && category?.trim().length > 0) {
|
if (
|
||||||
modifyEmoji({ id: emoji.id, category: category.trim() });
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -97,58 +87,62 @@ function EmojiDetail({ emoji }) {
|
||||||
<img src={emoji.url} alt={emoji.shortcode} title={emoji.shortcode} />
|
<img src={emoji.url} alt={emoji.shortcode} title={emoji.shortcode} />
|
||||||
<div>
|
<div>
|
||||||
<h2>{emoji.shortcode}</h2>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="left-border">
|
<form onSubmit={modifyEmoji} className="left-border">
|
||||||
<h2>Modify this emoji {modifyResult.isLoading && "(processing..)"}</h2>
|
<h2>Modify this emoji {result.isLoading && <Loading />}</h2>
|
||||||
|
|
||||||
{modifyResult.error && <div className="error">
|
|
||||||
{modifyResult.error.status}: {modifyResult.error.data.error}
|
|
||||||
</div>}
|
|
||||||
|
|
||||||
<div className="update-category">
|
<div className="update-category">
|
||||||
<CategorySelect
|
<CategorySelect
|
||||||
value={category}
|
field={form.category}
|
||||||
categoryState={categoryState}
|
|
||||||
setIsNew={setIsNewCategory}
|
|
||||||
>
|
>
|
||||||
<button style={{ visibility: (isNewCategory ? "initial" : "hidden") }} onClick={modifyCategory}>
|
<MutationButton
|
||||||
Create
|
name="create-category"
|
||||||
</button>
|
label="Create"
|
||||||
|
result={result}
|
||||||
|
showError={false}
|
||||||
|
style={{ visibility: (form.category.isNew ? "initial" : "hidden") }}
|
||||||
|
/>
|
||||||
</CategorySelect>
|
</CategorySelect>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="update-image">
|
<div className="update-image">
|
||||||
<b>Image</b>
|
<FileInput
|
||||||
<div className="form-field file">
|
field={form.image}
|
||||||
<label className="file-input button" htmlFor="image">
|
label="Image"
|
||||||
Browse
|
|
||||||
</label>
|
|
||||||
{imageInfo}
|
|
||||||
<input
|
|
||||||
className="hidden"
|
|
||||||
type="file"
|
|
||||||
id="image"
|
|
||||||
name="Image"
|
|
||||||
accept="image/png,image/gif"
|
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>
|
<FakeToot>
|
||||||
Look at this new custom emoji <img
|
Look at this new custom emoji <img
|
||||||
className="emoji"
|
className="emoji"
|
||||||
src={imageURL ?? emoji.url}
|
src={form.image.previewURL ?? emoji.url}
|
||||||
title={`:${emoji.shortcode}:`}
|
title={`:${emoji.shortcode}:`}
|
||||||
alt={emoji.shortcode}
|
alt={emoji.shortcode}
|
||||||
/> isn't it cool?
|
/> isn't it cool?
|
||||||
</FakeToot>
|
</FakeToot>
|
||||||
|
|
||||||
|
{result.error && <Error error={result.error} />}
|
||||||
|
{deleteResult.error && <Error error={deleteResult.error} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const React = require("react");
|
const React = require("react");
|
||||||
const {Switch, Route} = require("wouter");
|
const { Switch, Route } = require("wouter");
|
||||||
|
|
||||||
const EmojiOverview = require("./overview");
|
const EmojiOverview = require("./overview");
|
||||||
const EmojiDetail = require("./detail");
|
const EmojiDetail = require("./detail");
|
||||||
|
@ -31,9 +31,9 @@ module.exports = function CustomEmoji() {
|
||||||
<>
|
<>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path={`${base}/:emojiId`}>
|
<Route path={`${base}/:emojiId`}>
|
||||||
<EmojiDetail />
|
<EmojiDetail baseUrl={base} />
|
||||||
</Route>
|
</Route>
|
||||||
<EmojiOverview />
|
<EmojiOverview baseUrl={base} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -21,98 +21,65 @@
|
||||||
const Promise = require('bluebird');
|
const Promise = require('bluebird');
|
||||||
const React = require("react");
|
const React = require("react");
|
||||||
|
|
||||||
const FakeToot = require("../../../components/fake-toot");
|
const query = require("../../../lib/query");
|
||||||
const MutationButton = require("../../../components/form/mutation-button");
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
useTextInput,
|
useTextInput,
|
||||||
useFileInput,
|
useFileInput,
|
||||||
useComboBoxInput
|
useComboBoxInput
|
||||||
} = require("../../../lib/form");
|
} = 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 { 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 image = useFileInput("image", {
|
||||||
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", {
|
|
||||||
withPreview: true,
|
withPreview: true,
|
||||||
maxSize: 50 * 1024
|
maxSize: 50 * 1024 // TODO: get from instance api?
|
||||||
});
|
});
|
||||||
|
|
||||||
const [onShortcodeChange, resetShortcode, { shortcode, setShortcode, shortcodeRef }] = useTextInput("shortcode", {
|
const category = useComboBoxInput("category");
|
||||||
validator: function validateShortcode(code) {
|
|
||||||
// technically invalid, but hacky fix to prevent validation error on page load
|
|
||||||
if (shortcode == "") { return ""; }
|
|
||||||
|
|
||||||
if (emojiCodes.has(code)) {
|
const [submitForm, result] = useFormSubmit({
|
||||||
return "Shortcode already in use";
|
shortcode, image, category
|
||||||
}
|
}, query.useAddEmojiMutation());
|
||||||
|
|
||||||
if (code.length < 2 || code.length > 30) {
|
// const [onFileChange, resetFile, { image, imageURL, imageInfo }] = useFileInput("image", {
|
||||||
return "Shortcode must be between 2 and 30 characters";
|
// withPreview: true,
|
||||||
}
|
// maxSize: 50 * 1024
|
||||||
|
// });
|
||||||
|
|
||||||
if (code.toLowerCase() != code) {
|
// const [categoryState, resetCategory, { category }] = useComboBoxInput("category");
|
||||||
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");
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (shortcode.length == 0) {
|
if (shortcode.value.length == 0) {
|
||||||
if (image != undefined) {
|
if (image.value != undefined) {
|
||||||
let [name, _ext] = image.name.split(".");
|
let [name, _ext] = image.value.name.split(".");
|
||||||
setShortcode(name);
|
shortcode.setter(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// we explicitly don't want to add 'shortcode' as a dependency here
|
// 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
|
// 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
|
// at the moment the file is selected, not some time after when the field is emptied
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [image]);
|
}, [image.value]);
|
||||||
|
|
||||||
function uploadEmoji(e) {
|
let emojiOrShortcode = `:${shortcode.value}:`;
|
||||||
if (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
Promise.try(() => {
|
if (image.previewValue != undefined) {
|
||||||
return addEmoji({
|
|
||||||
image,
|
|
||||||
shortcode,
|
|
||||||
category
|
|
||||||
});
|
|
||||||
}).then((res) => {
|
|
||||||
if (res.error == undefined) {
|
|
||||||
resetFile();
|
|
||||||
resetShortcode();
|
|
||||||
resetCategory();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let emojiOrShortcode = `:${shortcode}:`;
|
|
||||||
|
|
||||||
if (imageURL != undefined) {
|
|
||||||
emojiOrShortcode = <img
|
emojiOrShortcode = <img
|
||||||
className="emoji"
|
className="emoji"
|
||||||
src={imageURL}
|
src={image.previewValue}
|
||||||
title={`:${shortcode}:`}
|
title={`:${shortcode}:`}
|
||||||
alt={shortcode}
|
alt={shortcode}
|
||||||
/>;
|
/>;
|
||||||
|
@ -126,39 +93,19 @@ module.exports = function NewEmojiForm({ emoji }) {
|
||||||
Look at this new custom emoji {emojiOrShortcode} isn't it cool?
|
Look at this new custom emoji {emojiOrShortcode} isn't it cool?
|
||||||
</FakeToot>
|
</FakeToot>
|
||||||
|
|
||||||
<form onSubmit={uploadEmoji} className="form-flex">
|
<form onSubmit={submitForm} className="form-flex">
|
||||||
<div className="form-field file">
|
<FileInput
|
||||||
<label className="file-input button" htmlFor="image">
|
field={image}
|
||||||
Browse
|
|
||||||
</label>
|
|
||||||
{imageInfo}
|
|
||||||
<input
|
|
||||||
className="hidden"
|
|
||||||
type="file"
|
|
||||||
id="image"
|
|
||||||
name="Image"
|
|
||||||
accept="image/png,image/gif"
|
accept="image/png,image/gif"
|
||||||
onChange={onFileChange}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-field text">
|
<TextInput
|
||||||
<label htmlFor="shortcode">
|
field={shortcode}
|
||||||
Shortcode, must be unique among the instance's local emoji
|
label="Shortcode, must be unique among the instance's local emoji"
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="shortcode"
|
|
||||||
name="Shortcode"
|
|
||||||
ref={shortcodeRef}
|
|
||||||
onChange={onShortcodeChange}
|
|
||||||
value={shortcode}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<CategorySelect
|
<CategorySelect
|
||||||
value={category}
|
field={category}
|
||||||
categoryState={categoryState}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MutationButton label="Upload emoji" result={result} />
|
<MutationButton label="Upload emoji" result={result} />
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const React = require("react");
|
const React = require("react");
|
||||||
const {Link} = require("wouter");
|
const { Link } = require("wouter");
|
||||||
|
|
||||||
const NewEmojiForm = require("./new-emoji");
|
const NewEmojiForm = require("./new-emoji");
|
||||||
|
|
||||||
|
@ -27,14 +27,12 @@ const query = require("../../../lib/query");
|
||||||
const { useEmojiByCategory } = require("../category-select");
|
const { useEmojiByCategory } = require("../category-select");
|
||||||
const Loading = require("../../../components/loading");
|
const Loading = require("../../../components/loading");
|
||||||
|
|
||||||
const base = "/settings/custom-emoji/local";
|
module.exports = function EmojiOverview({ baseUrl }) {
|
||||||
|
|
||||||
module.exports = function EmojiOverview() {
|
|
||||||
const {
|
const {
|
||||||
data: emoji = [],
|
data: emoji = [],
|
||||||
isLoading,
|
isLoading,
|
||||||
error
|
error
|
||||||
} = query.useGetAllEmojiQuery({filter: "domain:local"});
|
} = query.useGetAllEmojiQuery({ filter: "domain:local" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -43,17 +41,17 @@ module.exports = function EmojiOverview() {
|
||||||
<div className="error accent">{error}</div>
|
<div className="error accent">{error}</div>
|
||||||
}
|
}
|
||||||
{isLoading
|
{isLoading
|
||||||
? <Loading/>
|
? <Loading />
|
||||||
: <>
|
: <>
|
||||||
<EmojiList emoji={emoji}/>
|
<EmojiList emoji={emoji} baseUrl={baseUrl} />
|
||||||
<NewEmojiForm emoji={emoji}/>
|
<NewEmojiForm emoji={emoji} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function EmojiList({emoji}) {
|
function EmojiList({ emoji, baseUrl }) {
|
||||||
const emojiByCategory = useEmojiByCategory(emoji);
|
const emojiByCategory = useEmojiByCategory(emoji);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -62,24 +60,23 @@ function EmojiList({emoji}) {
|
||||||
<div className="list emoji-list">
|
<div className="list emoji-list">
|
||||||
{emoji.length == 0 && "No local emoji yet, add one below"}
|
{emoji.length == 0 && "No local emoji yet, add one below"}
|
||||||
{Object.entries(emojiByCategory).map(([category, entries]) => {
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmojiCategory({category, entries}) {
|
function EmojiCategory({ category, entries, baseUrl }) {
|
||||||
return (
|
return (
|
||||||
<div className="entry">
|
<div className="entry">
|
||||||
<b>{category}</b>
|
<b>{category}</b>
|
||||||
<div className="emoji-group">
|
<div className="emoji-group">
|
||||||
{entries.map((e) => {
|
{entries.map((e) => {
|
||||||
return (
|
return (
|
||||||
<Link key={e.id} to={`${base}/${e.id}`}>
|
<Link key={e.id} to={`${baseUrl}/${e.id}`}>
|
||||||
{/* <Link key={e.static_url} to={`${base}`}> */}
|
|
||||||
<a>
|
<a>
|
||||||
<img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`}/>
|
<img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`} />
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
|
@ -18,10 +18,9 @@
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const Promise = require("bluebird");
|
|
||||||
const React = require("react");
|
const React = require("react");
|
||||||
const Redux = require("react-redux");
|
|
||||||
const syncpipe = require("syncpipe");
|
const query = require("../../../lib/query");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
useTextInput,
|
useTextInput,
|
||||||
|
@ -34,44 +33,15 @@ const useFormSubmit = require("../../../lib/form/submit");
|
||||||
const CheckList = require("../../../components/check-list");
|
const CheckList = require("../../../components/check-list");
|
||||||
const { CategorySelect } = require('../category-select');
|
const { CategorySelect } = require('../category-select');
|
||||||
|
|
||||||
const query = require("../../../lib/query");
|
|
||||||
const Loading = require("../../../components/loading");
|
|
||||||
const { TextInput } = require("../../../components/form/inputs");
|
const { TextInput } = require("../../../components/form/inputs");
|
||||||
const MutationButton = require("../../../components/form/mutation-button");
|
const MutationButton = require("../../../components/form/mutation-button");
|
||||||
|
const { Error } = require("../../../components/error");
|
||||||
|
|
||||||
module.exports = function ParseFromToot({ emojiCodes }) {
|
module.exports = function ParseFromToot({ emojiCodes }) {
|
||||||
const [searchStatus, { data, isLoading, isSuccess, error }] = query.useSearchStatusForEmojiMutation();
|
const [searchStatus, result] = query.useSearchStatusForEmojiMutation();
|
||||||
const instanceDomain = Redux.useSelector((state) => (new URL(state.oauth.instance).host));
|
|
||||||
|
|
||||||
const [onURLChange, _resetURL, { url }] = useTextInput("url");
|
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) {
|
function submitSearch(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (url.trim().length != 0) {
|
if (url.trim().length != 0) {
|
||||||
|
@ -95,101 +65,143 @@ module.exports = function ParseFromToot({ emojiCodes }) {
|
||||||
onChange={onURLChange}
|
onChange={onURLChange}
|
||||||
value={url}
|
value={url}
|
||||||
/>
|
/>
|
||||||
<button className="button-inline" disabled={isLoading}>
|
<button className="button-inline" disabled={result.isLoading}>
|
||||||
<i className={[
|
<i className={[
|
||||||
"fa fa-fw",
|
"fa fa-fw",
|
||||||
(isLoading
|
(result.isLoading
|
||||||
? "fa-refresh fa-spin"
|
? "fa-refresh fa-spin"
|
||||||
: "fa-search")
|
: "fa-search")
|
||||||
].join(" ")} aria-hidden="true" title="Search" />
|
].join(" ")} aria-hidden="true" title="Search" />
|
||||||
<span className="sr-only">Search</span>
|
<span className="sr-only">Search</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="error">{error.data.error}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{searchResult}
|
<SearchResult result={result} localEmojiCodes={emojiCodes} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function CopyEmojiForm({ localEmojiCodes, type, domain, emojiList }) {
|
function SearchResult({ result, localEmojiCodes }) {
|
||||||
const [patchRemoteEmojis, patchResult] = query.usePatchRemoteEmojisMutation();
|
const { error, data, isSuccess, isError } = result;
|
||||||
const [err, setError] = React.useState();
|
|
||||||
|
|
||||||
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,
|
entries: emojiList,
|
||||||
uniqueKey: "shortcode"
|
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,
|
disabled: true,
|
||||||
title: "No emoji selected, cannot perform any actions"
|
title: "No emoji selected, cannot perform any actions"
|
||||||
};
|
};
|
||||||
|
|
||||||
function submit(action) {
|
// function submit(action) {
|
||||||
Promise.try(() => {
|
// Promise.try(() => {
|
||||||
setError(null);
|
// setError(null);
|
||||||
const selectedShortcodes = emojiCheckList.selectedValues.map(([shortcode, entry]) => {
|
// const selectedShortcodes = emojiCheckList.selectedValues.map(([shortcode, entry]) => {
|
||||||
if (action == "copy" && !entry.valid) {
|
// if (action == "copy" && !entry.valid) {
|
||||||
throw `One or more selected emoji have non-unique shortcodes (${shortcode}), unselect them or pick a different local shortcode`;
|
// throw `One or more selected emoji have non-unique shortcodes (${shortcode}), unselect them or pick a different local shortcode`;
|
||||||
}
|
// }
|
||||||
return {
|
// return {
|
||||||
shortcode,
|
// shortcode,
|
||||||
localShortcode: entry.shortcode
|
// localShortcode: entry.shortcode
|
||||||
};
|
// };
|
||||||
});
|
// });
|
||||||
|
|
||||||
return patchRemoteEmojis({
|
// return patchRemoteEmojis({
|
||||||
action,
|
// action,
|
||||||
domain,
|
// domain,
|
||||||
list: selectedShortcodes,
|
// list: selectedShortcodes,
|
||||||
category
|
// category
|
||||||
}).unwrap();
|
// }).unwrap();
|
||||||
}).then(() => {
|
// }).then(() => {
|
||||||
emojiCheckList.reset();
|
// emojiCheckList.reset();
|
||||||
resetCategory();
|
// resetCategory();
|
||||||
}).catch((e) => {
|
// }).catch((e) => {
|
||||||
if (Array.isArray(e)) {
|
// if (Array.isArray(e)) {
|
||||||
setError(e.map(([shortcode, msg]) => (
|
// setError(e.map(([shortcode, msg]) => (
|
||||||
<div key={shortcode}>
|
// <div key={shortcode}>
|
||||||
{shortcode}: <span style={{ fontWeight: "initial" }}>{msg}</span>
|
// {shortcode}: <span style={{ fontWeight: "initial" }}>{msg}</span>
|
||||||
</div>
|
// </div>
|
||||||
)));
|
// )));
|
||||||
} else {
|
// } else {
|
||||||
setError(e);
|
// setError(e);
|
||||||
}
|
// }
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="parsed">
|
<div className="parsed">
|
||||||
<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>
|
<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>
|
||||||
|
<form onSubmit={formSubmit}>
|
||||||
<CheckList
|
<CheckList
|
||||||
field={emojiCheckList}
|
field={form.selectedEmoji}
|
||||||
Component={EmojiEntry}
|
Component={EmojiEntry}
|
||||||
localEmojiCodes={localEmojiCodes}
|
localEmojiCodes={localEmojiCodes}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CategorySelect
|
<CategorySelect
|
||||||
value={category}
|
field={form.category}
|
||||||
categoryState={categoryState}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="action-buttons row">
|
<div className="action-buttons row">
|
||||||
<MutationButton label="Copy to local emoji" type="button" result={patchResult} {...buttonsInactive} />
|
<MutationButton name="copy" label="Copy to local emoji" result={result} showError={false} {...buttonsInactive} />
|
||||||
<MutationButton label="Disable" type="button" result={patchResult} className="button danger" {...buttonsInactive} />
|
<MutationButton name="disable" label="Disable" result={result} className="button danger" showError={false} {...buttonsInactive} />
|
||||||
</div>
|
</div>
|
||||||
{err && <div className="error">
|
{result.error && (
|
||||||
{err}
|
Array.isArray(result.error)
|
||||||
</div>}
|
? <ErrorList errors={result.error} />
|
||||||
{patchResult.isSuccess && <div>
|
: <Error error={result.error} />
|
||||||
Action applied to {patchResult.data.length} emoji
|
)}
|
||||||
</div>}
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@
|
||||||
const React = require("react");
|
const React = require("react");
|
||||||
|
|
||||||
const query = require("../../lib/query");
|
const query = require("../../lib/query");
|
||||||
|
const processDomainList = require("../../lib/import-export");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
useTextInput,
|
useTextInput,
|
||||||
|
@ -34,37 +35,71 @@ const {
|
||||||
TextInput,
|
TextInput,
|
||||||
TextArea,
|
TextArea,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
FileInput
|
FileInput,
|
||||||
|
useCheckListInput
|
||||||
} = require("../../components/form/inputs");
|
} = require("../../components/form/inputs");
|
||||||
|
|
||||||
const FormWithData = require("../../lib/form/form-with-data");
|
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() {
|
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 (
|
return (
|
||||||
<div className="import-export">
|
<div className="import-export">
|
||||||
<h2>Import / Export</h2>
|
<h2>Import / Export</h2>
|
||||||
<FormWithData
|
<div>
|
||||||
dataQuery={query.useInstanceBlocksQuery}
|
{
|
||||||
DataForm={ImportExportForm}
|
parsedList
|
||||||
/>
|
? <ImportExportList list={parsedList} />
|
||||||
|
: <ImportExportForm form={form} submitImport={submitImport} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function ImportExportForm({ data: blockedInstances }) {
|
function ImportExportList({ list }) {
|
||||||
const form = {
|
const entryCheckList = useCheckListInput("selectedDomains", {
|
||||||
list: useTextInput("list"),
|
entries: list,
|
||||||
obfuscate: useBoolInput("obfuscate"),
|
uniqueKey: "domain"
|
||||||
commentPrivate: useTextInput("private_comment"),
|
});
|
||||||
commentPublic: useTextInput("public_comment"),
|
|
||||||
json: useFileInput("json")
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form>
|
<CheckList
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportExportForm({ form, submitImport }) {
|
||||||
|
return (
|
||||||
|
<form onSubmit={submitImport}>
|
||||||
<TextArea
|
<TextArea
|
||||||
field={form.list}
|
field={form.domains}
|
||||||
label="Domains, one per line"
|
label="Domains, one per line (plaintext) or JSON"
|
||||||
placeholder={`google.com\nfacebook.com`}
|
placeholder={`google.com\nfacebook.com`}
|
||||||
|
rows={8}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextArea
|
<TextArea
|
||||||
|
@ -83,6 +118,10 @@ function ImportExportForm({ data: blockedInstances }) {
|
||||||
field={form.obfuscate}
|
field={form.obfuscate}
|
||||||
label="Obfuscate domain in public lists"
|
label="Obfuscate domain in public lists"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<MutationButton label="Import" result={importResult} /> {/* default form action */}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -25,13 +25,14 @@ const baseUrl = `/settings/admin/federation`;
|
||||||
|
|
||||||
const InstanceOverview = require("./overview");
|
const InstanceOverview = require("./overview");
|
||||||
const InstanceDetail = require("./detail");
|
const InstanceDetail = require("./detail");
|
||||||
|
const InstanceImportExport = require("./import-export");
|
||||||
|
|
||||||
module.exports = function Federation({ }) {
|
module.exports = function Federation({ }) {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
{/* <Route path={`${baseUrl}/import-export`}>
|
<Route path={`${baseUrl}/import-export`}>
|
||||||
<InstanceImportExport />
|
<InstanceImportExport />
|
||||||
</Route> */}
|
</Route>
|
||||||
|
|
||||||
<Route path={`${baseUrl}/:domain`}>
|
<Route path={`${baseUrl}/:domain`}>
|
||||||
<InstanceDetail baseUrl={baseUrl} />
|
<InstanceDetail baseUrl={baseUrl} />
|
||||||
|
|
|
@ -72,7 +72,7 @@ module.exports = function InstanceOverview({ baseUrl }) {
|
||||||
<span>
|
<span>
|
||||||
{blockedInstances.length} blocked instance{blockedInstances.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered)`}
|
{blockedInstances.length} blocked instance{blockedInstances.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered)`}
|
||||||
</span>
|
</span>
|
||||||
<div className="list">
|
<div className="list scrolling">
|
||||||
{filteredInstances.map((entry) => {
|
{filteredInstances.map((entry) => {
|
||||||
return (
|
return (
|
||||||
<Link key={entry.domain} to={`${baseUrl}/${entry.domain}`}>
|
<Link key={entry.domain} to={`${baseUrl}/${entry.domain}`}>
|
||||||
|
@ -90,8 +90,7 @@ module.exports = function InstanceOverview({ baseUrl }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Link to={`${baseUrl}/import-export`}><a>Or use the bulk import/export interface</a></Link>
|
||||||
<ImportExport />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
|
@ -26,21 +26,21 @@ const {
|
||||||
ComboboxPopover,
|
ComboboxPopover,
|
||||||
} = require("ariakit/combobox");
|
} = require("ariakit/combobox");
|
||||||
|
|
||||||
module.exports = function ComboBox({state, items, label, placeHolder, children}) {
|
module.exports = function ComboBox({ field, items, label, children, ...inputProps }) {
|
||||||
return (
|
return (
|
||||||
<div className="form-field combobox-wrapper">
|
<div className="form-field combobox-wrapper">
|
||||||
<label>
|
<label>
|
||||||
{label}
|
{label}
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<Combobox
|
<Combobox
|
||||||
state={state}
|
state={field.state}
|
||||||
placeholder={placeHolder}
|
|
||||||
className="combobox input"
|
className="combobox input"
|
||||||
|
{...inputProps}
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<ComboboxPopover state={state} className="popover">
|
<ComboboxPopover state={field.state} className="popover">
|
||||||
{items.map(([key, value]) => (
|
{items.map(([key, value]) => (
|
||||||
<ComboboxItem className="combobox-item" key={key} value={key}>
|
<ComboboxItem className="combobox-item" key={key} value={key}>
|
||||||
{value}
|
{value}
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
|
|
||||||
const React = require("react");
|
const React = require("react");
|
||||||
|
|
||||||
module.exports = function ErrorFallback({error, resetErrorBoundary}) {
|
function ErrorFallback({ error, resetErrorBoundary }) {
|
||||||
return (
|
return (
|
||||||
<div className="error">
|
<div className="error">
|
||||||
<p>
|
<p>
|
||||||
|
@ -28,7 +28,7 @@ module.exports = function ErrorFallback({error, resetErrorBoundary}) {
|
||||||
<a href="https://github.com/superseriousbusiness/gotosocial/issues">GoToSocial issue tracker</a>
|
<a href="https://github.com/superseriousbusiness/gotosocial/issues">GoToSocial issue tracker</a>
|
||||||
{" or "}
|
{" or "}
|
||||||
<a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>.
|
<a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>.
|
||||||
<br/>Include the details below:
|
<br />Include the details below:
|
||||||
</p>
|
</p>
|
||||||
<pre>
|
<pre>
|
||||||
{error.name}: {error.message}
|
{error.name}: {error.message}
|
||||||
|
@ -41,4 +41,33 @@ module.exports = function ErrorFallback({error, resetErrorBoundary}) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 };
|
|
@ -60,7 +60,7 @@ function FileInput({ label, field, ...inputProps }) {
|
||||||
return (
|
return (
|
||||||
<div className="form-field file">
|
<div className="form-field file">
|
||||||
<label>
|
<label>
|
||||||
{label}
|
<div className="label">{label}</div>
|
||||||
<div className="file-input button">Browse</div>
|
<div className="file-input button">Browse</div>
|
||||||
{infoComponent}
|
{infoComponent}
|
||||||
{/* <a onClick={removeFile("header")}>remove</a> */}
|
{/* <a onClick={removeFile("header")}>remove</a> */}
|
||||||
|
|
|
@ -19,23 +19,30 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const React = require("react");
|
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 = "";
|
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) {
|
if (result.isLoading) {
|
||||||
iconClass = "fa-spin fa-refresh";
|
iconClass = "fa-spin fa-refresh";
|
||||||
} else if (result.isSuccess) {
|
} else if (result.isSuccess) {
|
||||||
iconClass = "fa-check fadeout";
|
iconClass = "fa-check fadeout";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (<div>
|
return (<div>
|
||||||
{result.error &&
|
{(showError && targetsThisButton && result.error) &&
|
||||||
<section className="error">{result.error.status}: {result.error.data.error}</section>
|
<Error error={result.error} />
|
||||||
}
|
}
|
||||||
<button type="submit" disabled={result.isLoading || disabled} {...inputProps}>
|
<button type="submit" className={"with-icon " + className} disabled={result.isLoading || disabled} {...inputProps}>
|
||||||
<i className={`fa fa-fw with-text ${iconClass}`} aria-hidden="true"></i>
|
<i className={`fa fa-fw ${iconClass}`} aria-hidden="true"></i>
|
||||||
{result.isLoading
|
{(targetsThisButton && result.isLoading)
|
||||||
? "Processing..."
|
? "Processing..."
|
||||||
: label
|
: label
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,11 +24,12 @@ const Redux = require("react-redux");
|
||||||
|
|
||||||
const { setInstance } = require("../redux/reducers/oauth").actions;
|
const { setInstance } = require("../redux/reducers/oauth").actions;
|
||||||
const api = require("../lib/api");
|
const api = require("../lib/api");
|
||||||
|
const { Error } = require("./error");
|
||||||
|
|
||||||
module.exports = function Login({error}) {
|
module.exports = function Login({ error }) {
|
||||||
const dispatch = Redux.useDispatch();
|
const dispatch = Redux.useDispatch();
|
||||||
const [ instanceField, setInstanceField ] = React.useState("");
|
const [instanceField, setInstanceField] = React.useState("");
|
||||||
const [ errorMsg, setErrorMsg ] = React.useState();
|
const [loginError, setLoginError] = React.useState();
|
||||||
const instanceFieldRef = React.useRef("");
|
const instanceFieldRef = React.useRef("");
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
@ -65,12 +66,7 @@ module.exports = function Login({error}) {
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
return dispatch(api.oauth.authorize()); // will send user off-page
|
return dispatch(api.oauth.authorize()); // will send user off-page
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
setErrorMsg(
|
setLoginError(e);
|
||||||
<>
|
|
||||||
<b>{e.type}</b>
|
|
||||||
<span>{e.message}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -89,11 +85,9 @@ module.exports = function Login({error}) {
|
||||||
{error}
|
{error}
|
||||||
<form onSubmit={(e) => e.preventDefault()}>
|
<form onSubmit={(e) => e.preventDefault()}>
|
||||||
<label htmlFor="instance">Instance: </label>
|
<label htmlFor="instance">Instance: </label>
|
||||||
<input value={instanceField} onChange={updateInstanceField} id="instance"/>
|
<input value={instanceField} onChange={updateInstanceField} id="instance" />
|
||||||
{errorMsg &&
|
{loginError &&
|
||||||
<div className="error">
|
<Error error={loginError} />
|
||||||
{errorMsg}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
<button onClick={tryInstance}>Authenticate</button>
|
<button onClick={tryInstance}>Authenticate</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -33,6 +33,7 @@ const { AuthenticationError } = require("./lib/errors");
|
||||||
|
|
||||||
const Login = require("./components/login");
|
const Login = require("./components/login");
|
||||||
const Loading = require("./components/loading");
|
const Loading = require("./components/loading");
|
||||||
|
const { Error } = require("./components/error");
|
||||||
|
|
||||||
require("./style.css");
|
require("./style.css");
|
||||||
|
|
||||||
|
@ -103,12 +104,7 @@ function App() {
|
||||||
|
|
||||||
let ErrorElement = null;
|
let ErrorElement = null;
|
||||||
if (errorMsg != undefined) {
|
if (errorMsg != undefined) {
|
||||||
ErrorElement = (
|
ErrorElement = <Error error={errorMsg} />;
|
||||||
<div className="error">
|
|
||||||
<b>{errorMsg.type}</b>
|
|
||||||
<span>{errorMsg.message}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const LogoutElement = (
|
const LogoutElement = (
|
||||||
|
|
|
@ -85,7 +85,7 @@ module.exports = function ({ apiCall, getChanges }) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidDomain(line, {wildcard: true, allowUnicode: true})) {
|
if (!isValidDomain(line, { wildcard: true, allowUnicode: true })) {
|
||||||
invalidDomains.push(line);
|
invalidDomains.push(line);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -103,7 +103,7 @@ module.exports = function ({ apiCall, getChanges }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const update = {
|
const update = {
|
||||||
domains: new Blob([JSON.stringify(domains)], {type: "application/json"})
|
domains: new Blob([JSON.stringify(domains)], { type: "application/json" })
|
||||||
};
|
};
|
||||||
|
|
||||||
return dispatch(apiCall("POST", "/api/v1/admin/domain_blocks?import=true", update, "form"));
|
return dispatch(apiCall("POST", "/api/v1/admin/domain_blocks?import=true", update, "form"));
|
||||||
|
|
|
@ -18,9 +18,13 @@
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
const React = require("react");
|
||||||
|
|
||||||
const { useComboboxState } = require("ariakit/combobox");
|
const { useComboboxState } = require("ariakit/combobox");
|
||||||
|
|
||||||
module.exports = function useComboBoxInput({ name, Name }, { defaultValue } = {}) {
|
module.exports = function useComboBoxInput({ name, Name }, { defaultValue } = {}) {
|
||||||
|
const [isNew, setIsNew] = React.useState(false);
|
||||||
|
|
||||||
const state = useComboboxState({
|
const state = useComboboxState({
|
||||||
defaultValue,
|
defaultValue,
|
||||||
gutter: 0,
|
gutter: 0,
|
||||||
|
@ -36,11 +40,17 @@ module.exports = function useComboBoxInput({ name, Name }, { defaultValue } = {}
|
||||||
reset,
|
reset,
|
||||||
{
|
{
|
||||||
[name]: state.value,
|
[name]: state.value,
|
||||||
name
|
name,
|
||||||
|
[`${name}IsNew`]: isNew,
|
||||||
|
[`set${Name}IsNew`]: setIsNew
|
||||||
}
|
}
|
||||||
], {
|
], {
|
||||||
name,
|
name,
|
||||||
|
state,
|
||||||
value: state.value,
|
value: state.value,
|
||||||
|
hasChanged: () => state.value != defaultValue,
|
||||||
|
isNew,
|
||||||
|
setIsNew,
|
||||||
reset
|
reset
|
||||||
});
|
});
|
||||||
};
|
};
|
|
@ -6,12 +6,16 @@ const Loading = require("../../components/loading");
|
||||||
|
|
||||||
// Wrap Form component inside component that fires the RTK Query call,
|
// 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
|
// so Form will only be rendered when data is available to generate form-fields for
|
||||||
module.exports = function FormWithData({dataQuery, DataForm}) {
|
module.exports = function FormWithData({ dataQuery, DataForm, arg }) {
|
||||||
const {data, isLoading} = dataQuery();
|
const { data, isLoading } = dataQuery(arg);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Loading/>;
|
return (
|
||||||
|
<div>
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return <DataForm data={data}/>;
|
return <DataForm data={data} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
|
@ -33,6 +33,13 @@ module.exports = {
|
||||||
useTextInput: makeHook(require("./text")),
|
useTextInput: makeHook(require("./text")),
|
||||||
useFileInput: makeHook(require("./file")),
|
useFileInput: makeHook(require("./file")),
|
||||||
useBoolInput: makeHook(require("./bool")),
|
useBoolInput: makeHook(require("./bool")),
|
||||||
useComboBoxInput: makeHook(require("./combobox")),
|
useComboBoxInput: makeHook(require("./combo-box")),
|
||||||
useCheckListInput: makeHook(require("./check-list"))
|
useCheckListInput: makeHook(require("./check-list")),
|
||||||
|
useValue: function (name, value) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
hasChanged: () => true // always included
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
|
@ -18,31 +18,57 @@
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
const Promise = require("bluebird");
|
||||||
|
const React = require("react");
|
||||||
const syncpipe = require("syncpipe");
|
const syncpipe = require("syncpipe");
|
||||||
|
|
||||||
module.exports = function useFormSubmit(form, [mutationQuery, result], { changedOnly = true } = {}) {
|
module.exports = function useFormSubmit(form, [mutationQuery, result], { changedOnly = true } = {}) {
|
||||||
|
const [usedAction, setUsedAction] = React.useState();
|
||||||
return [
|
return [
|
||||||
function submitForm(e) {
|
function submitForm(e) {
|
||||||
|
let action;
|
||||||
|
if (e?.preventDefault) {
|
||||||
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
|
// transform the field definitions into an object with just their values
|
||||||
let updatedFields = [];
|
let updatedFields = [];
|
||||||
const mutationData = syncpipe(form, [
|
const mutationData = syncpipe(form, [
|
||||||
(_) => Object.values(_),
|
(_) => Object.values(_),
|
||||||
(_) => _.map((field) => {
|
(_) => _.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);
|
updatedFields.push(field);
|
||||||
return [field.name, field.value];
|
return [field.name, field.value];
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}),
|
}),
|
||||||
(_) => _.filter((value) => value != null),
|
(_) => _.filter((value) => value != null),
|
||||||
(_) => Object.fromEntries(_)
|
(_) => Object.fromEntries(_)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
mutationData.action = action;
|
||||||
|
|
||||||
|
return Promise.try(() => {
|
||||||
return mutationQuery(mutationData);
|
return mutationQuery(mutationData);
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.error == undefined) {
|
||||||
|
updatedFields.forEach((field) => {
|
||||||
|
field.reset();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
result
|
{
|
||||||
|
...result,
|
||||||
|
action: usedAction
|
||||||
|
}
|
||||||
];
|
];
|
||||||
};
|
};
|
|
@ -22,7 +22,7 @@ const React = require("react");
|
||||||
const { Link, Route, Redirect } = require("wouter");
|
const { Link, Route, Redirect } = require("wouter");
|
||||||
const { ErrorBoundary } = require("react-error-boundary");
|
const { ErrorBoundary } = require("react-error-boundary");
|
||||||
|
|
||||||
const ErrorFallback = require("../components/error");
|
const { ErrorFallback } = require("../components/error");
|
||||||
const NavButton = require("../components/nav-button");
|
const NavButton = require("../components/nav-button");
|
||||||
|
|
||||||
function urlSafe(str) {
|
function urlSafe(str) {
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
};
|
|
@ -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({
|
||||||
|
// })
|
||||||
|
});
|
|
@ -22,8 +22,8 @@ const {
|
||||||
replaceCacheOnMutation,
|
replaceCacheOnMutation,
|
||||||
appendCacheOnMutation,
|
appendCacheOnMutation,
|
||||||
spliceCacheOnMutation
|
spliceCacheOnMutation
|
||||||
} = require("./lib");
|
} = require("../lib");
|
||||||
const base = require("./base");
|
const base = require("../base");
|
||||||
|
|
||||||
const endpoints = (build) => ({
|
const endpoints = (build) => ({
|
||||||
updateInstance: build.mutation({
|
updateInstance: build.mutation({
|
||||||
|
@ -70,7 +70,8 @@ const endpoints = (build) => ({
|
||||||
return draft.findIndex((block) => block.id == newData.id);
|
return draft.findIndex((block) => block.id == newData.id);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
}),
|
||||||
|
...require("./federation-bulk")(build)
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = base.injectEndpoints({ endpoints });
|
module.exports = base.injectEndpoints({ endpoints });
|
|
@ -37,12 +37,14 @@ const endpoints = (build) => ({
|
||||||
? [...res.map((emoji) => ({ type: "Emojis", id: emoji.id })), { type: "Emojis", id: "LIST" }]
|
? [...res.map((emoji) => ({ type: "Emojis", id: emoji.id })), { type: "Emojis", id: "LIST" }]
|
||||||
: [{ type: "Emojis", id: "LIST" }]
|
: [{ type: "Emojis", id: "LIST" }]
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getEmoji: build.query({
|
getEmoji: build.query({
|
||||||
query: (id) => ({
|
query: (id) => ({
|
||||||
url: `/api/v1/admin/custom_emojis/${id}`
|
url: `/api/v1/admin/custom_emojis/${id}`
|
||||||
}),
|
}),
|
||||||
providesTags: (res, error, id) => [{ type: "Emojis", id }]
|
providesTags: (res, error, id) => [{ type: "Emojis", id }]
|
||||||
}),
|
}),
|
||||||
|
|
||||||
addEmoji: build.mutation({
|
addEmoji: build.mutation({
|
||||||
query: (form) => {
|
query: (form) => {
|
||||||
return {
|
return {
|
||||||
|
@ -58,6 +60,7 @@ const endpoints = (build) => ({
|
||||||
? [{ type: "Emojis", id: "LIST" }, { type: "Emojis", id: res.id }]
|
? [{ type: "Emojis", id: "LIST" }, { type: "Emojis", id: res.id }]
|
||||||
: [{ type: "Emojis", id: "LIST" }]
|
: [{ type: "Emojis", id: "LIST" }]
|
||||||
}),
|
}),
|
||||||
|
|
||||||
editEmoji: build.mutation({
|
editEmoji: build.mutation({
|
||||||
query: ({ id, ...patch }) => {
|
query: ({ id, ...patch }) => {
|
||||||
return {
|
return {
|
||||||
|
@ -75,6 +78,7 @@ const endpoints = (build) => ({
|
||||||
? [{ type: "Emojis", id: "LIST" }, { type: "Emojis", id: res.id }]
|
? [{ type: "Emojis", id: "LIST" }, { type: "Emojis", id: res.id }]
|
||||||
: [{ type: "Emojis", id: "LIST" }]
|
: [{ type: "Emojis", id: "LIST" }]
|
||||||
}),
|
}),
|
||||||
|
|
||||||
deleteEmoji: build.mutation({
|
deleteEmoji: build.mutation({
|
||||||
query: (id) => ({
|
query: (id) => ({
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
@ -82,75 +86,73 @@ const endpoints = (build) => ({
|
||||||
}),
|
}),
|
||||||
invalidatesTags: (res, error, id) => [{ type: "Emojis", id }]
|
invalidatesTags: (res, error, id) => [{ type: "Emojis", id }]
|
||||||
}),
|
}),
|
||||||
|
|
||||||
searchStatusForEmoji: build.mutation({
|
searchStatusForEmoji: build.mutation({
|
||||||
query: (url) => ({
|
queryFn: (url, api, _extraOpts, baseQuery) => {
|
||||||
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) => {
|
|
||||||
return Promise.try(() => {
|
return Promise.try(() => {
|
||||||
return baseQuery({
|
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`,
|
url: `/api/v1/admin/custom_emojis`,
|
||||||
params: {
|
params: {
|
||||||
filter: `domain:${domain},shortcode:${emoji.shortcode}`,
|
filter: `domain:${domain},shortcode:${emoji.shortcode}`,
|
||||||
limit: 1
|
limit: 1
|
||||||
}
|
}
|
||||||
}).then(unwrapRes);
|
}).then((unwrapRes)).then((list) => list[0]);
|
||||||
}).then(([lookup]) => {
|
}, { concurrency: 5 }).then((listWithIDs) => {
|
||||||
if (lookup == undefined) { throw "not found"; }
|
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 = {
|
let body = {
|
||||||
type: action
|
type: action
|
||||||
};
|
};
|
||||||
|
|
||||||
if (action == "copy") {
|
if (action == "copy") {
|
||||||
body.shortcode = emoji.localShortcode ?? emoji.shortcode;
|
body.shortcode = emoji.shortcode;
|
||||||
if (category.trim().length != 0) {
|
if (formData.category.trim().length != 0) {
|
||||||
body.category = category;
|
body.category = formData.category;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseQuery({
|
return baseQuery({
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
url: `/api/v1/admin/custom_emojis/${lookup.id}`,
|
url: `/api/v1/admin/custom_emojis/${emoji.id}`,
|
||||||
asForm: true,
|
asForm: true,
|
||||||
body: body
|
body: body
|
||||||
}).then(unwrapRes);
|
}).then(unwrapRes);
|
||||||
}).then((res) => {
|
}).then((res) => {
|
||||||
data.push([emoji.shortcode, res]);
|
data.push([emoji.shortcode, res]);
|
||||||
}).catch((e) => {
|
}).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;
|
let msg = e.message ?? e;
|
||||||
if (e.data.error) {
|
if (e.data.error) {
|
||||||
msg = 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 });
|
module.exports = base.injectEndpoints({ endpoints });
|
|
@ -16,6 +16,8 @@
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
$fa-fw: 1.28571429em; /* Fork-Awesome 'fa-fw' fixed icon width */
|
||||||
|
|
||||||
body {
|
body {
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
}
|
}
|
||||||
|
@ -225,6 +227,7 @@ section.with-sidebar > div, section.with-sidebar > form {
|
||||||
|
|
||||||
.button-inline {
|
.button-inline {
|
||||||
width: auto;
|
width: auto;
|
||||||
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type=checkbox] {
|
input[type=checkbox] {
|
||||||
|
@ -344,8 +347,12 @@ form {
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field.file label {
|
.form-field.file label {
|
||||||
width: 100%;
|
display: grid;
|
||||||
display: flex;
|
grid-template-columns: auto 1fr;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
grid-column: 1 / span 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
span.form-info {
|
span.form-info {
|
||||||
|
@ -598,6 +605,12 @@ span.form-info {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
height: 8.5rem;
|
height: 8.5rem;
|
||||||
width: 8.5rem;
|
width: 8.5rem;
|
||||||
|
@ -608,15 +621,13 @@ span.form-info {
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-category {
|
.update-category {
|
||||||
margin-bottom: 1rem;
|
|
||||||
.combobox-wrapper button {
|
.combobox-wrapper button {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
margin: 0.15rem 0;
|
margin: 0.15rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.1rem;
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -681,8 +692,14 @@ span.form-info {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button .fa.with-text {
|
button.with-icon {
|
||||||
margin-left: -1.28571429em;
|
display: flex;
|
||||||
|
align-content: center;
|
||||||
|
padding-right: calc(0.5rem + $fa-fw);
|
||||||
|
|
||||||
|
.fa {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fadeout {
|
.fadeout {
|
||||||
|
|
Loading…
Reference in New Issue