moderation panel cleanup

This commit is contained in:
BFlat committed 2023-04-30 15:09:26 +07:00
1 parent 78ecef35a6
commit b7fa2823f6
6 files changed
+625 -454

No files matched your search

@@ -1,24 +1,35 @@
import React, { useCallback, useEffect, useState } from 'react';
import { BackLink } from '../../../_common/BackLink';
import { Category, GetCategory, GetCategoryIsError, GetCategoryIsOk } from '@storyteller/components/src/api/category/GetCategory';
import { WebUrl } from '../../../../../common/WebUrl';
import { Link, useHistory } from 'react-router-dom';
import { SessionWrapper } from '@storyteller/components/src/session/SessionWrapper';
import { useParams } from 'react-router-dom';
import { SetCategoryDeletionState, SetCategoryDeletionStateIsError, SetCategoryDeletionStateIsSuccess } from '@storyteller/components/src/api/moderation/category/SetCategoryDeletionState';
import React, { useCallback, useEffect, useState } from "react";
import { BackLink } from "../../../_common/BackLink";
import {
Category,
GetCategory,
GetCategoryIsError,
GetCategoryIsOk,
} from "@storyteller/components/src/api/category/GetCategory";
import { WebUrl } from "../../../../../common/WebUrl";
import { Link, useHistory } from "react-router-dom";
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
import { useParams } from "react-router-dom";
import {
SetCategoryDeletionState,
SetCategoryDeletionStateIsError,
SetCategoryDeletionStateIsSuccess,
} from "@storyteller/components/src/api/moderation/category/SetCategoryDeletionState";
interface Props {
sessionWrapper: SessionWrapper,
sessionWrapper: SessionWrapper;
}
function ModerationCategoryDeletePage(props: Props) {
const { token } : { token : string } = useParams();
const { token }: { token: string } = useParams();
const history = useHistory();
const [category, setCategory] = useState<Category|undefined>(undefined);
const [category, setCategory] = useState<Category | undefined>(undefined);
const [errorMessage, setErrorMessage] = useState<string|undefined>(undefined);
const [errorMessage, setErrorMessage] = useState<string | undefined>(
undefined
);
const getCategory = useCallback(async (categoryToken: string) => {
const categoryList = await GetCategory(categoryToken);
@@ -26,11 +37,11 @@ function ModerationCategoryDeletePage(props: Props) {
if (GetCategoryIsOk(categoryList)) {
const category = categoryList.category;
setCategory(category);
} else if (GetCategoryIsError(categoryList)) {
} else if (GetCategoryIsError(categoryList)) {
setErrorMessage("error fetching category");
}
}, []);
useEffect(() => {
getCategory(token);
}, [token, getCategory]);
@@ -40,31 +51,37 @@ function ModerationCategoryDeletePage(props: Props) {
}
if (category === undefined) {
return <div />
return <div />;
}
const currentlyDeleted = !!category?.deleted_at;
const deletePageTitle = currentlyDeleted ? "Undelete Category?" : "Delete Category?";
const deleteButtonTitle = currentlyDeleted ? "Confirm Undelete Category" : "Confirm Delete Category";
const deletePageTitle = currentlyDeleted
? "Undelete Category?"
: "Delete Category?";
const deleteButtonTitle = currentlyDeleted
? "Confirm Undelete Category"
: "Confirm Delete Category";
const deleteButtonCss = currentlyDeleted ?
"button is-warning is-large is-fullwidth" :
"button is-danger is-large is-fullwidth";
const deleteButtonCss = currentlyDeleted
? "btn btn-destructive w-100"
: "btn btn-destructive w-100";
const handleFormSubmit = async (ev: React.FormEvent<HTMLFormElement>) : Promise<boolean> => {
const handleFormSubmit = async (
ev: React.FormEvent<HTMLFormElement>
): Promise<boolean> => {
ev.preventDefault();
let response = await SetCategoryDeletionState(token, !currentlyDeleted);
if (SetCategoryDeletionStateIsError(response)) {
setErrorMessage('there was an error with the request'); // TODO: Fix error serialization
setErrorMessage("there was an error with the request"); // TODO: Fix error serialization
} else if (SetCategoryDeletionStateIsSuccess(response)) {
history.push(WebUrl.moderationTtsCategoryEdit(token));
}
return false;
}
};
let errorFlash = <></>;
@@ -73,19 +90,20 @@ function ModerationCategoryDeletePage(props: Props) {
<>
<br />
<article className="message is-error">
<div className="message-body">
{errorMessage}
</div>
<div className="message-body">{errorMessage}</div>
</article>
</>
);
}
return (
<div>
<h1 className="title is-1"> {deletePageTitle} </h1>
<div className="container pt-5">
<h1 className="fw-bold">{deletePageTitle}</h1>
<BackLink link={WebUrl.moderationTtsCategoryEdit(token)} text="Back to category edit page" />
<BackLink
link={WebUrl.moderationTtsCategoryEdit(token)}
text="Back to category edit page"
/>
<br />
@@ -96,57 +114,54 @@ function ModerationCategoryDeletePage(props: Props) {
<table className="table is-fullwidth">
<thead>
<tr>
<th><abbr title="Detail">Detail</abbr></th>
<th><abbr title="Value">Value</abbr></th>
<th>
<abbr title="Detail">Detail</abbr>
</th>
<th>
<abbr title="Value">Value</abbr>
</th>
</tr>
</thead>
<tbody>
<tr>
<th>Category Name</th>
<td>
{category.name}
</td>
<td>{category.name}</td>
</tr>
<tr>
<th>Creator</th>
<td>
<Link
<Link
to={WebUrl.userProfilePage(category?.creator_username || "")}
>{category?.creator_username}</Link>
>
{category?.creator_username}
</Link>
</td>
</tr>
<tr>
<th>Created On</th>
<td>
{category.created_at}
</td>
<td>{category.created_at}</td>
</tr>
<tr>
<th>Is Mod Approved?</th>
<td>
{category.is_mod_approved ? "Yes" : "No"}
</td>
<td>{category.is_mod_approved ? "Yes" : "No"}</td>
</tr>
<tr>
<th>Is Currently Deleted?</th>
<td>
{currentlyDeleted ? "Yes" : "No"}
</td>
<td>{currentlyDeleted ? "Yes" : "No"}</td>
</tr>
</tbody>
</table>
<form onSubmit={handleFormSubmit}>
<form onSubmit={handleFormSubmit} className="my-4">
<button className={deleteButtonCss}> {deleteButtonTitle} </button>
</form>
<br />
<BackLink link={WebUrl.moderationTtsCategoryEdit(token)} text="Back to category edit page" />
<BackLink
link={WebUrl.moderationTtsCategoryEdit(token)}
text="Back to category edit page"
/>
</div>
)
);
}
export { ModerationCategoryDeletePage };
@@ -1,39 +1,62 @@
import React, { useCallback, useEffect, useState } from 'react';
import { BackLink } from '../../../_common/BackLink';
import { Category, GetCategory, GetCategoryIsError, GetCategoryIsOk } from '@storyteller/components/src/api/category/GetCategory';
import { WebUrl } from '../../../../../common/WebUrl';
import { Link, useHistory } from 'react-router-dom';
import { SessionWrapper } from '@storyteller/components/src/session/SessionWrapper';
import { useParams } from 'react-router-dom';
import { EditCategory, EditCategoryIsError, EditCategoryIsSuccess, EditCategoryRequest } from '@storyteller/components/src/api/moderation/category/EditCategory';
import { ListTtsCategories, ListTtsCategoriesIsError, ListTtsCategoriesIsOk, TtsCategory } from '@storyteller/components/src/api/category/ListTtsCategories';
import { CategoryBreadcrumb } from '../../../_common/CategoryBreadcrumb';
import React, { useCallback, useEffect, useState } from "react";
import { BackLink } from "../../../_common/BackLink";
import {
Category,
GetCategory,
GetCategoryIsError,
GetCategoryIsOk,
} from "@storyteller/components/src/api/category/GetCategory";
import { WebUrl } from "../../../../../common/WebUrl";
import { Link, useHistory } from "react-router-dom";
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
import { useParams } from "react-router-dom";
import {
EditCategory,
EditCategoryIsError,
EditCategoryIsSuccess,
EditCategoryRequest,
} from "@storyteller/components/src/api/moderation/category/EditCategory";
import {
ListTtsCategories,
ListTtsCategoriesIsError,
ListTtsCategoriesIsOk,
TtsCategory,
} from "@storyteller/components/src/api/category/ListTtsCategories";
import { CategoryBreadcrumb } from "../../../_common/CategoryBreadcrumb";
interface Props {
sessionWrapper: SessionWrapper,
sessionWrapper: SessionWrapper;
}
function ModerationTtsCategoryEditPage(props: Props) {
const { token } : { token : string } = useParams();
const { token }: { token: string } = useParams();
const history = useHistory();
const [category, setCategory] = useState<Category|undefined>(undefined);
const [category, setCategory] = useState<Category | undefined>(undefined);
// Fields
const [name, setName] = useState('');
const [maybeSuperCategoryToken, setMaybeSuperCategoryToken] = useState<string|undefined>(undefined); // Optional
const [maybeDropdownName, setMaybeDropdownName] = useState<string|undefined>(undefined); // Optional
const [name, setName] = useState("");
const [maybeSuperCategoryToken, setMaybeSuperCategoryToken] = useState<
string | undefined
>(undefined); // Optional
const [maybeDropdownName, setMaybeDropdownName] = useState<
string | undefined
>(undefined); // Optional
const [canDirectlyHaveModels, setCanDirectlyHaveModels] = useState(false);
const [canHaveSubcategories, setCanHaveSubcategories] = useState(false);
const [canOnlyModsApply, setCanOnlyModsApply] = useState(false);
const [isModApproved, setIsModApproved] = useState(false);
const [maybeModComments, setMaybeModComments] = useState<string|undefined>(undefined); // Optional
const [maybeModComments, setMaybeModComments] = useState<string | undefined>(
undefined
); // Optional
// Additional object lookups to support parent categories
const [allTtsCategories, setAllTtsCategories] = useState<TtsCategory[]>([]);
const [errorMessage, setErrorMessage] = useState<string|undefined>(undefined);
const [errorMessage, setErrorMessage] = useState<string | undefined>(
undefined
);
const getCategory = useCallback(async (categoryToken: string) => {
const categoryList = await GetCategory(categoryToken);
@@ -49,17 +72,17 @@ function ModerationTtsCategoryEditPage(props: Props) {
setCanOnlyModsApply(category.can_only_mods_apply);
setIsModApproved(category.is_mod_approved || false); // Default to false
setMaybeModComments(category.maybe_mod_comments);
} else if (GetCategoryIsError(categoryList)) {
} else if (GetCategoryIsError(categoryList)) {
setErrorMessage("error fetching category");
}
}, []);
const listTtsCategories = useCallback(async () => {
const categoryList = await ListTtsCategories();
if (ListTtsCategoriesIsOk(categoryList)) {
setAllTtsCategories(categoryList.categories);
} else if (ListTtsCategoriesIsError(categoryList)) {
} else if (ListTtsCategoriesIsError(categoryList)) {
setErrorMessage("error listing all categories");
}
}, []);
@@ -74,52 +97,68 @@ function ModerationTtsCategoryEditPage(props: Props) {
}
if (category === undefined) {
return <div />
return <div />;
}
const handleNameChange = (ev: React.FormEvent<HTMLInputElement>) => {
const value = (ev.target as HTMLInputElement).value;
setName(value);
}
const handleMaybeDropdownNameChange = (ev: React.FormEvent<HTMLInputElement>) => {
let value = (ev.target as HTMLInputElement).value;
setMaybeDropdownName( !!value ? value : undefined);
}
const handleCanDirectlyHaveModelsChange = (ev: React.FormEvent<HTMLInputElement>) => {
const value = (ev.target as HTMLInputElement).checked;
setCanDirectlyHaveModels(value);
}
const handleCanHaveSubcategoriesChange = (ev: React.FormEvent<HTMLInputElement>) => {
const value = (ev.target as HTMLInputElement).checked;
setCanHaveSubcategories(value);
}
const handleCanOnlyModsApplyChange = (ev: React.FormEvent<HTMLInputElement>) => {
const value = (ev.target as HTMLInputElement).checked;
setCanOnlyModsApply(value);
}
const handleIsModApprovedChange = (ev: React.FormEvent<HTMLSelectElement>) => {
const value = (ev.target as HTMLSelectElement).value;
const updatedValue = value === "true" ? true : false;
setIsModApproved(updatedValue)
};
const handleSetSuperCategory = async (ev: React.FormEvent<HTMLSelectElement>) => {
const superCategoryToken = (ev.target as HTMLSelectElement).value;
let fixedSuperCategoryToken = !!superCategoryToken ? superCategoryToken : undefined;
setMaybeSuperCategoryToken(fixedSuperCategoryToken);
}
const handleMaybeDropdownNameChange = (
ev: React.FormEvent<HTMLInputElement>
) => {
let value = (ev.target as HTMLInputElement).value;
setMaybeDropdownName(!!value ? value : undefined);
};
const handleFormSubmit = async (ev: React.FormEvent<HTMLFormElement>) : Promise<boolean> => {
const handleCanDirectlyHaveModelsChange = (
ev: React.FormEvent<HTMLInputElement>
) => {
const value = (ev.target as HTMLInputElement).checked;
setCanDirectlyHaveModels(value);
};
const handleCanHaveSubcategoriesChange = (
ev: React.FormEvent<HTMLInputElement>
) => {
const value = (ev.target as HTMLInputElement).checked;
setCanHaveSubcategories(value);
};
const handleCanOnlyModsApplyChange = (
ev: React.FormEvent<HTMLInputElement>
) => {
const value = (ev.target as HTMLInputElement).checked;
setCanOnlyModsApply(value);
};
const handleIsModApprovedChange = (
ev: React.FormEvent<HTMLSelectElement>
) => {
const value = (ev.target as HTMLSelectElement).value;
const updatedValue = value === "true" ? true : false;
setIsModApproved(updatedValue);
};
const handleSetSuperCategory = async (
ev: React.FormEvent<HTMLSelectElement>
) => {
const superCategoryToken = (ev.target as HTMLSelectElement).value;
let fixedSuperCategoryToken = !!superCategoryToken
? superCategoryToken
: undefined;
setMaybeSuperCategoryToken(fixedSuperCategoryToken);
};
const handleFormSubmit = async (
ev: React.FormEvent<HTMLFormElement>
): Promise<boolean> => {
ev.preventDefault();
setErrorMessage(undefined);
let request : EditCategoryRequest = {
let request: EditCategoryRequest = {
name: name,
maybe_dropdown_name: maybeDropdownName,
maybe_mod_comments: maybeModComments,
@@ -128,18 +167,18 @@ function ModerationTtsCategoryEditPage(props: Props) {
can_have_subcategories: canHaveSubcategories,
can_only_mods_apply: canOnlyModsApply,
is_mod_approved: isModApproved,
}
};
const response = await EditCategory(token, request);
if (EditCategoryIsError(response)) {
setErrorMessage('there was an error with the request'); // TODO: Fix error serialization
setErrorMessage("there was an error with the request"); // TODO: Fix error serialization
} else if (EditCategoryIsSuccess(response)) {
history.go(0); // NB: Force reload
}
return false;
}
};
let errorFlash = <></>;
@@ -148,38 +187,42 @@ function ModerationTtsCategoryEditPage(props: Props) {
<>
<br />
<article className="message is-error">
<div className="message-body">
{errorMessage}
</div>
<div className="message-body">{errorMessage}</div>
</article>
</>
);
}
const isModApprovedHtmlFormState = isModApproved ? "true" : "false";
const maybeSuperCategoryTokenFormHtmlState = maybeSuperCategoryToken ? maybeSuperCategoryToken : "";
const maybeSuperCategoryTokenFormHtmlState = maybeSuperCategoryToken
? maybeSuperCategoryToken
: "";
const superCategoryOptions = allTtsCategories.filter(category => {
const isSelf = token === category.category_token;
const cannotAdd = !category.can_have_subcategories;
return !isSelf && !cannotAdd;
}).map(category => {
return (
<>
<option
value={category.category_token}
key={category.category_token}>{category.name}</option>
</>
)
});
const superCategoryOptions = allTtsCategories
.filter((category) => {
const isSelf = token === category.category_token;
const cannotAdd = !category.can_have_subcategories;
return !isSelf && !cannotAdd;
})
.map((category) => {
return (
<>
<option value={category.category_token} key={category.category_token}>
{category.name}
</option>
</>
);
});
const currentlyDeleted = !!category?.deleted_at;
const deleteButtonTitle = currentlyDeleted ? "Undelete Category?" : "Delete Category?";
const deleteButtonTitle = currentlyDeleted
? "Undelete Category?"
: "Delete Category?";
const deleteButtonCss = currentlyDeleted ?
"button is-warning is-large is-fullwidth" :
"button is-danger is-large is-fullwidth";
const deleteButtonCss = currentlyDeleted
? "btn btn-destructive w-100"
: "btn btn-destructive w-100";
let deletedNotice = <></>;
@@ -199,140 +242,184 @@ function ModerationTtsCategoryEditPage(props: Props) {
const categoryHierarchy = recursiveBuildHierarchy(allTtsCategories, token);
return (
<div>
<h1 className="title is-1"> Moderate TTS Category </h1>
<div className="container pt-5">
<h1 className="fw-bold"> Moderate TTS Category </h1>
<BackLink link={WebUrl.moderationTtsCategoryList()} text="Back to category list" />
<BackLink
link={WebUrl.moderationTtsCategoryList()}
text="Back to category list"
/>
<br />
<br />
<CategoryBreadcrumb categoryHierarchy={categoryHierarchy} isCategoryMod={true} leafHasModels={false} />
<br />
<CategoryBreadcrumb
categoryHierarchy={categoryHierarchy}
isCategoryMod={true}
leafHasModels={false}
/>
{errorFlash}
{deletedNotice}
<br />
<form
onSubmit={handleFormSubmit}
className="d-flex flex-column gap-4 mt-5"
>
<div>
<label className="sub-title">
Mod Approval (sets public list visibility)
</label>
<form onSubmit={handleFormSubmit}>
<label className="label">Mod Approval (sets public list visibility)</label>
<div className="select is-info is-large">
<select name="approve" value={isModApprovedHtmlFormState} onChange={handleIsModApprovedChange}>
<option value="true">Approve</option>
<option value="false">Disapprove</option>
</select>
</div>
<br />
<br />
<div className="field">
<label className="label">Category Name</label>
<div className="control">
<input className="input" type="text" placeholder="Category Name" value={name} onChange={handleNameChange} />
<div className="select is-info is-large">
<select
name="approve"
value={isModApprovedHtmlFormState}
onChange={handleIsModApprovedChange}
className="form-select rounded"
>
<option value="true">Approve</option>
<option value="false">Disapprove</option>
</select>
</div>
</div>
<br />
<div className="field">
<label className="label">Dropdown Name Override (optional)</label>
<div className="control">
<input
className="input"
type="text"
placeholder="Dropdown Name"
value={maybeDropdownName || ""}
onChange={handleMaybeDropdownNameChange} />
</div>
<p>(eg. if the category name is "Gender", this might be named "By Gender" for the dropdown.)</p>
<div>
<label className="sub-title">Category Name</label>
<input
className="form-control"
type="text"
placeholder="Category Name"
value={name}
onChange={handleNameChange}
/>
</div>
<br />
<label className="label">Permission Flags</label>
<label className="checkbox">
<input
type="checkbox"
checked={canOnlyModsApply}
onChange={handleCanOnlyModsApplyChange} />
&nbsp;Can only mods apply this category? (Model authors can't add this themselves. For "Best of" and other special categories.)
</label>
<br />
<br />
<label className="label">Topology (Children)</label>
<label className="checkbox">
<input
type="checkbox"
checked={canDirectlyHaveModels}
onChange={handleCanDirectlyHaveModelsChange} />
&nbsp;Can this category be directly assigned to models? (If not, it's only a super category.)
</label>
<br />
<label className="checkbox">
<input
type="checkbox"
checked={canHaveSubcategories}
onChange={handleCanHaveSubcategoriesChange} />
&nbsp;Can this category have subcategories?
</label>
<br />
<br />
<label className="label">Topology (Optional Parent Category)</label>
<div className="field">
<div>
<label className="sub-title">Dropdown Name Override (optional)</label>
<div className="control">
<div className="select is-info">
<select onChange={handleSetSuperCategory} value={maybeSuperCategoryTokenFormHtmlState}>
<option value="" key="">None (this is optional)</option>
{superCategoryOptions}
</select>
</div>
<input
className="form-control"
type="text"
placeholder="Dropdown Name"
value={maybeDropdownName || ""}
onChange={handleMaybeDropdownNameChange}
/>
</div>
<p className="form-text">
(eg. if the category name is "Gender", this might be named "By
Gender" for the dropdown.)
</p>
</div>
<div className="d-flex flex-column py-3">
<label className="sub-title">Permission Flags</label>
<div className="form-check">
<input
type="checkbox"
checked={canOnlyModsApply}
onChange={handleCanOnlyModsApplyChange}
className="form-check-input"
id="checkCanOnlyModsApplyChange"
/>
<label
className="form-check-label"
htmlFor="checkCanOnlyModsApplyChange"
>
Can only mods apply this category? (Model authors can't add this
themselves. For "Best of" and other special categories.)
</label>
</div>
<label className="sub-title">Topology (Children)</label>
<div className="form-check">
<input
type="checkbox"
checked={canDirectlyHaveModels}
onChange={handleCanDirectlyHaveModelsChange}
className="form-check-input"
id="checkCanDirectlyHaveModelsChange"
/>
<label
className="form-check-label"
htmlFor="checkCanDirectlyHaveModelsChange"
>
Can this category be directly assigned to models? (If not, it's
only a super category.)
</label>
</div>
<div className="form-check">
<input
type="checkbox"
checked={canHaveSubcategories}
onChange={handleCanHaveSubcategoriesChange}
id="checkCanHaveSubcategoriesChange"
className="form-check-input"
/>
<label htmlFor="checkCanHaveSubcategoriesChange">
Can this category have subcategories?
</label>
</div>
</div>
<br />
<div>
<label className="sub-title">
Topology (Optional Parent Category)
</label>
<button className="button is-link is-large is-fullwidth"> Save Changes </button>
<div className="form-group">
<select
onChange={handleSetSuperCategory}
value={maybeSuperCategoryTokenFormHtmlState}
className="form-select rounded"
>
<option value="" key="">
None (this is optional)
</option>
{superCategoryOptions}
</select>
</div>
</div>
<div className="d-flex gap-3 my-4">
<button className="btn btn-primary w-100">Save Changes</button>
<Link
className={deleteButtonCss}
to={WebUrl.moderationCategoryDeletePage(token)}
>
{deleteButtonTitle}
</Link>
</div>
</form>
<br />
<Link
className={deleteButtonCss}
to={WebUrl.moderationCategoryDeletePage(token)}
>{deleteButtonTitle}</Link>
<br />
<BackLink link={WebUrl.moderationTtsCategoryList()} text="Back to category list" />
<div className="d-flex flex-column gap-2">
<BackLink
link={WebUrl.moderationTtsCategoryList()}
text="Back to category list"
/>
</div>
</div>
)
);
}
// FIXME: This has been implemented three times, slightly differently
function recursiveBuildHierarchy(allCategories: TtsCategory[], currentToken: string): TtsCategory[] {
let found = allCategories.find(category => category.category_token === currentToken);
function recursiveBuildHierarchy(
allCategories: TtsCategory[],
currentToken: string
): TtsCategory[] {
let found = allCategories.find(
(category) => category.category_token === currentToken
);
if (found === undefined) {
return [];
}
if (found.maybe_super_category_token === undefined) {
return [found];
}
return [...recursiveBuildHierarchy(allCategories, found.maybe_super_category_token), found];
return [
...recursiveBuildHierarchy(allCategories, found.maybe_super_category_token),
found,
];
}
export { ModerationTtsCategoryEditPage };
@@ -1,37 +1,53 @@
import React, { useCallback, useEffect, useState } from 'react';
import { SessionWrapper } from '@storyteller/components/src/session/SessionWrapper';
import { Gravatar } from '@storyteller/components/src/elements/Gravatar';
import { WebUrl } from '../../../../../common/WebUrl';
import { BackLink } from '../../../_common/BackLink';
import { ListTtsCategoriesForModeration, ListTtsCategoriesForModerationIsError, ListTtsCategoriesForModerationIsOk, ListTtsCategoriesTriState, ModerationTtsCategory } from '@storyteller/components/src/api/moderation/category/ListTtsCategoriesForModeration';
import { Link } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBox, faBoxOpen, faLock } from '@fortawesome/free-solid-svg-icons';
import { CategoryBreadcrumb } from '../../../_common/CategoryBreadcrumb';
import React, { useCallback, useEffect, useState } from "react";
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
// import { Gravatar } from "@storyteller/components/src/elements/Gravatar";
import { WebUrl } from "../../../../../common/WebUrl";
import { BackLink } from "../../../_common/BackLink";
import {
ListTtsCategoriesForModeration,
ListTtsCategoriesForModerationIsError,
ListTtsCategoriesForModerationIsOk,
ListTtsCategoriesTriState,
ModerationTtsCategory,
} from "@storyteller/components/src/api/moderation/category/ListTtsCategoriesForModeration";
import { Link } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBox, faBoxOpen, faLock } from "@fortawesome/free-solid-svg-icons";
import { CategoryBreadcrumb } from "../../../_common/CategoryBreadcrumb";
interface Props {
sessionWrapper: SessionWrapper,
sessionWrapper: SessionWrapper;
}
interface SortableCategoryHierarchy {
hierarchy: ModerationTtsCategory[],
hierarchy: ModerationTtsCategory[];
// NB: While we could pull out the last item of the hierarchy list, this is more convenient.
category: ModerationTtsCategory,
category: ModerationTtsCategory;
}
function ModerationTtsCategoryListPage(props: Props) {
const [ttsCategories, setTtsCategories] = useState<ModerationTtsCategory[]>([]);
const [errorMessage, setErrorMessage] = useState<string|undefined>(undefined);
const [ttsCategories, setTtsCategories] = useState<ModerationTtsCategory[]>(
[]
);
const [errorMessage, setErrorMessage] = useState<string | undefined>(
undefined
);
const [deletedView, setDeletedView] = useState<ListTtsCategoriesTriState>(ListTtsCategoriesTriState.Exclude);
const [unapprovedView, setUnapprovedView] = useState<ListTtsCategoriesTriState>(ListTtsCategoriesTriState.Include);
const [deletedView, setDeletedView] = useState<ListTtsCategoriesTriState>(
ListTtsCategoriesTriState.Exclude
);
const [unapprovedView, setUnapprovedView] =
useState<ListTtsCategoriesTriState>(ListTtsCategoriesTriState.Include);
const listTtsCategories = useCallback(async () => {
const categoryList = await ListTtsCategoriesForModeration(deletedView, unapprovedView);
const categoryList = await ListTtsCategoriesForModeration(
deletedView,
unapprovedView
);
if (ListTtsCategoriesForModerationIsOk(categoryList)) {
setTtsCategories(categoryList.categories);
} else if (ListTtsCategoriesForModerationIsError(categoryList)) {
} else if (ListTtsCategoriesForModerationIsError(categoryList)) {
setErrorMessage("error listing all categories");
}
}, [deletedView, unapprovedView]);
@@ -50,7 +66,7 @@ function ModerationTtsCategoryListPage(props: Props) {
if (maybeTriState !== undefined) {
setDeletedView(maybeTriState);
}
}
};
const handleUnapprovedChange = (ev: React.FormEvent<HTMLInputElement>) => {
const value = (ev.target as HTMLInputElement).value;
@@ -58,7 +74,7 @@ function ModerationTtsCategoryListPage(props: Props) {
if (maybeTriState !== undefined) {
setUnapprovedView(maybeTriState);
}
}
};
let errorFlash = <></>;
@@ -66,80 +82,89 @@ function ModerationTtsCategoryListPage(props: Props) {
errorFlash = (
<>
<article className="message is-error">
<div className="message-body">
{errorMessage}
</div>
<div className="message-body">{errorMessage}</div>
</article>
</>
);
}
let sortableHierarchies : SortableCategoryHierarchy[] = ttsCategories.map(category => {
const categoryHierarchy = recursiveBuildHierarchy(ttsCategories, category.category_token);
return {
hierarchy: categoryHierarchy,
category: category,
}
}).sort((first, second) => {
let maxLength = Math.max(first.hierarchy.length, second.hierarchy.length);
for (let i = 0; i < maxLength; i++) {
let f = first.hierarchy[i];
let s = second.hierarchy[i];
if (f !== undefined && s !== undefined) {
let compared = f.name.localeCompare(s.name)
if (compared !== 0) {
return compared;
let sortableHierarchies: SortableCategoryHierarchy[] = ttsCategories
.map((category) => {
const categoryHierarchy = recursiveBuildHierarchy(
ttsCategories,
category.category_token
);
return {
hierarchy: categoryHierarchy,
category: category,
};
})
.sort((first, second) => {
let maxLength = Math.max(first.hierarchy.length, second.hierarchy.length);
for (let i = 0; i < maxLength; i++) {
let f = first.hierarchy[i];
let s = second.hierarchy[i];
if (f !== undefined && s !== undefined) {
let compared = f.name.localeCompare(s.name);
if (compared !== 0) {
return compared;
}
} else if (f === undefined) {
return -1;
} else if (s === undefined) {
return 1;
}
} else if (f === undefined) {
return -1;
} else if (s === undefined) {
return 1;
}
}
return -1;
});
return -1;
});
return (
<div>
<h1 className="title is-1"> Moderate TTS Categories </h1>
<div className="container pt-5">
<h1 className="fw-bold"> Moderate TTS Categories </h1>
<BackLink link={WebUrl.moderationMain()} text="Back to moderation" />
<br />
<Link
to={WebUrl.createCategoryPage()}
className="btn btn-primary w-100 my-4"
>
Create new category
</Link>
{errorFlash}
<br />
<div className="control">
<strong>Show Unapproved:</strong>
&nbsp;
<label className="radio">
<input
type="radio"
name="unapproved"
value="include"
<input
type="radio"
name="unapproved"
value="include"
checked={unapprovedView === ListTtsCategoriesTriState.Include}
onChange={handleUnapprovedChange} />
onChange={handleUnapprovedChange}
/>
Include
</label>
<label className="radio">
<input
type="radio"
name="unapproved"
value="exclude"
<input
type="radio"
name="unapproved"
value="exclude"
checked={unapprovedView === ListTtsCategoriesTriState.Exclude}
onChange={handleUnapprovedChange} />
onChange={handleUnapprovedChange}
/>
Exclude
</label>
<label className="radio">
<input
type="radio"
name="unapproved"
value="only"
<input
type="radio"
name="unapproved"
value="only"
checked={unapprovedView === ListTtsCategoriesTriState.Only}
onChange={handleUnapprovedChange} />
onChange={handleUnapprovedChange}
/>
Only
</label>
</div>
@@ -148,36 +173,39 @@ function ModerationTtsCategoryListPage(props: Props) {
<strong>Show Deleted:</strong>
&nbsp;
<label className="radio">
<input
type="radio"
name="deleted"
value="include"
<input
type="radio"
name="deleted"
value="include"
checked={deletedView === ListTtsCategoriesTriState.Include}
onChange={handleDeletedChange} />
onChange={handleDeletedChange}
/>
Include
</label>
<label className="radio">
<input
type="radio"
name="deleted"
value="exclude"
<input
type="radio"
name="deleted"
value="exclude"
checked={deletedView === ListTtsCategoriesTriState.Exclude}
onChange={handleDeletedChange} />
onChange={handleDeletedChange}
/>
Exclude
</label>
<label className="radio">
<input
type="radio"
name="deleted"
value="only"
<input
type="radio"
name="deleted"
value="only"
checked={deletedView === ListTtsCategoriesTriState.Only}
onChange={handleDeletedChange} />
onChange={handleDeletedChange}
/>
Only
</label>
</div>
<br />
<table className="table is-fullwidth">
<thead>
<tr>
@@ -189,70 +217,98 @@ function ModerationTtsCategoryListPage(props: Props) {
</tr>
</thead>
<tbody>
{sortableHierarchies.map(sortableHierarchy => {
{sortableHierarchies.map((sortableHierarchy) => {
const category = sortableHierarchy.category;
const categoryHierarchy = sortableHierarchy.hierarchy;
let modOnlyIcon = category.can_only_mods_apply ?
<>&nbsp;<FontAwesomeIcon icon={faLock} title={"mod only"} /></> :
<></>;
let modOnlyIcon = category.can_only_mods_apply ? (
<>
&nbsp;
<FontAwesomeIcon icon={faLock} title={"mod only"} />
</>
) : (
<></>
);
let parentCategoryIcon = <></>;
if (category.can_have_subcategories) {
if (category.can_directly_have_models) {
parentCategoryIcon = <><FontAwesomeIcon icon={faBoxOpen} title={"can have subcategories and models"} />&nbsp;</>
parentCategoryIcon = (
<>
<FontAwesomeIcon
icon={faBoxOpen}
title={"can have subcategories and models"}
/>
&nbsp;
</>
);
} else {
parentCategoryIcon = <><FontAwesomeIcon icon={faBox} title={"can have subcategories"} />&nbsp;</>
parentCategoryIcon = (
<>
<FontAwesomeIcon
icon={faBox}
title={"can have subcategories"}
/>
&nbsp;
</>
);
}
}
let creatorLink = <span />;
if (!!category?.creator_display_name) {
const creatorUrl = WebUrl.userProfilePage(category?.creator_username || "username error");
const creatorUrl = WebUrl.userProfilePage(
category?.creator_username || "username error"
);
creatorLink = (
<span className="white-space-nowrap">
<Gravatar
{/* <Gravatar
size={15}
username={category.creator_display_name || ""}
email_hash={category.creator_gravatar_hash || ""}
/>
username={category.creator_display_name || ""}
email_hash={category.creator_gravatar_hash || ""}
/> */}
&nbsp;
<Link to={creatorUrl}>{category.creator_display_name}</Link>
</span>
);
}
let approved = 'not set';
let approved = "not set";
if (category.is_mod_approved === undefined) {
approved = 'not set';
approved = "not set";
} else if (category.is_mod_approved === true) {
approved = 'approved';
approved = "approved";
} else if (category.is_mod_approved === false) {
approved = 'DISAPPROVED';
approved = "DISAPPROVED";
}
let deleted = !!category.deleted_at ? 'DELETED' : 'No';
let deleted = !!category.deleted_at ? "DELETED" : "No";
return (
<tr key={category.category_token}>
<td>
{parentCategoryIcon}
{modOnlyIcon}
<CategoryBreadcrumb categoryHierarchy={categoryHierarchy} isCategoryMod={true} leafHasModels={false} disableLinks={true} />
<CategoryBreadcrumb
categoryHierarchy={categoryHierarchy}
isCategoryMod={true}
leafHasModels={false}
disableLinks={true}
/>
</td>
<td>{creatorLink}</td>
<td>{approved}</td>
<td>{deleted}</td>
<td>
{creatorLink}
</td>
<td>
{approved}
</td>
<td>
{deleted}
</td>
<td>
<Link to={WebUrl.moderationTtsCategoryEdit(category.category_token)}>edit</Link>
<Link
to={WebUrl.moderationTtsCategoryEdit(
category.category_token
)}
>
edit
</Link>
</td>
</tr>
);
@@ -260,48 +316,49 @@ function ModerationTtsCategoryListPage(props: Props) {
</tbody>
</table>
<p>
<strong>Note: </strong> Only approved, non-deleted categories will show up publicly.
New category suggestions by non-mods are "unapproved" by default.
Use "deletion" to hide categories you don't want to deal with anymore.
<p className="my-4">
<strong>Note: </strong> Only approved, non-deleted categories will show
up publicly. New category suggestions by non-mods are "unapproved" by
default. Use "deletion" to hide categories you don't want to deal with
anymore.
</p>
<br />
<Link
to={WebUrl.createCategoryPage()}
className="button is-info is-large is-fullwidth"
>Create new category</Link>
<br />
<BackLink link={WebUrl.moderationMain()} text="Back to moderation" />
</div>
)
);
}
function StringToTriState(state: string) : ListTtsCategoriesTriState | undefined {
function StringToTriState(
state: string
): ListTtsCategoriesTriState | undefined {
switch (state) {
case 'include':
case "include":
return ListTtsCategoriesTriState.Include;
case 'exclude':
case "exclude":
return ListTtsCategoriesTriState.Exclude;
case 'only':
case "only":
return ListTtsCategoriesTriState.Only;
}
}
// FIXME: This has been implemented three times, slightly differently
function recursiveBuildHierarchy(allCategories: ModerationTtsCategory[], currentToken: string): ModerationTtsCategory[] {
let found = allCategories.find(category => category.category_token === currentToken);
function recursiveBuildHierarchy(
allCategories: ModerationTtsCategory[],
currentToken: string
): ModerationTtsCategory[] {
let found = allCategories.find(
(category) => category.category_token === currentToken
);
if (found === undefined) {
return [];
}
if (found.maybe_super_category_token === undefined) {
return [found];
}
return [...recursiveBuildHierarchy(allCategories, found.maybe_super_category_token), found];
return [
...recursiveBuildHierarchy(allCategories, found.maybe_super_category_token),
found,
];
}
export { ModerationTtsCategoryListPage };
@@ -1,19 +1,25 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { SessionWrapper } from '@storyteller/components/src/session/SessionWrapper';
import { Gravatar } from '@storyteller/components/src/elements/Gravatar';
import { formatDistance } from 'date-fns';
import { WebUrl } from '../../../../../common/WebUrl';
import { BackLink } from '../../../_common/BackLink';
import { GetPendingW2lTemplates, GetPendingW2lTemplatesIsOk, PendingW2lTemplatesEntryForList } from '@storyteller/components/src/api/moderation/w2l/GetPendingW2lTemplates';
import { PhotoVideoIcon } from '../../../_icons/PhotoVideoIcon';
import React, { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
import { Gravatar } from "@storyteller/components/src/elements/Gravatar";
import { formatDistance } from "date-fns";
import { WebUrl } from "../../../../../common/WebUrl";
import { BackLink } from "../../../_common/BackLink";
import {
GetPendingW2lTemplates,
GetPendingW2lTemplatesIsOk,
PendingW2lTemplatesEntryForList,
} from "@storyteller/components/src/api/moderation/w2l/GetPendingW2lTemplates";
import { PhotoVideoIcon } from "../../../_icons/PhotoVideoIcon";
interface Props {
sessionWrapper: SessionWrapper,
sessionWrapper: SessionWrapper;
}
function ModerationPendingW2lTemplatesFc(props: Props) {
const [templates, setTemplates] = useState<Array<PendingW2lTemplatesEntryForList>>([]);
const [templates, setTemplates] = useState<
Array<PendingW2lTemplatesEntryForList>
>([]);
const getUsers = useCallback(async () => {
const response = await GetPendingW2lTemplates();
@@ -21,16 +27,14 @@ function ModerationPendingW2lTemplatesFc(props: Props) {
if (GetPendingW2lTemplatesIsOk(response)) {
setTemplates(response.templates);
}
}, []);
useEffect(() => {
getUsers();
}, [getUsers]);
if (!templates) {
return <div />
return <div />;
}
if (!props.sessionWrapper.canApproveW2lTemplates()) {
@@ -38,12 +42,14 @@ function ModerationPendingW2lTemplatesFc(props: Props) {
}
const now = new Date();
let rows : Array<JSX.Element> = [];
let rows: Array<JSX.Element> = [];
templates.forEach(template=> {
templates.forEach((template) => {
const createTime = new Date(template.created_at);
const relativeCreateTime = formatDistance(createTime, now, { addSuffix: true });
const relativeCreateTime = formatDistance(createTime, now, {
addSuffix: true,
});
rows.push(
<tr key={template.template_token}>
<td>
@@ -55,7 +61,11 @@ function ModerationPendingW2lTemplatesFc(props: Props) {
</td>
<td>
<Link to={WebUrl.userProfilePage(template.creator_display_name)}>
<Gravatar username={template.creator_display_name} email_hash={template.creator_gravatar_hash} size={12} />
<Gravatar
username={template.creator_display_name}
email_hash={template.creator_gravatar_hash}
size={12}
/>
&nbsp;
{template.creator_display_name}
</Link>
@@ -63,12 +73,12 @@ function ModerationPendingW2lTemplatesFc(props: Props) {
<td>{template.template_type}</td>
<td>{relativeCreateTime}</td>
</tr>
)
);
});
return (
<div>
<h1 className="title is-1"> Unapproved W2L Templates </h1>
<div className="container pt-5">
<h1 className="fw-bold"> Unapproved W2L Templates </h1>
<BackLink link={WebUrl.moderationMain()} text="Back to moderation" />
@@ -82,22 +92,22 @@ function ModerationPendingW2lTemplatesFc(props: Props) {
<br />
<p>
Don't delete templates unless they overwhelmingly break the rules.
Users can use templates in private without them necessarily being approved for public use.
Don't delete templates unless they overwhelmingly break the rules. Users
can use templates in private without them necessarily being approved for
public use.
</p>
<br />
<p>
Setting approved = TRUE will show the template on the main site for everyone to use.
Setting approved = FALSE will still let the author and people directly accessing via
URL to use the template, but will not make it public.
It will also remove it from the moderation queue.
Setting approved = TRUE will show the template on the main site for
everyone to use. Setting approved = FALSE will still let the author and
people directly accessing via URL to use the template, but will not make
it public. It will also remove it from the moderation queue.
</p>
<br />
<table className="table is-fullwidth">
<thead>
<tr>
@@ -107,12 +117,10 @@ function ModerationPendingW2lTemplatesFc(props: Props) {
<th>Created At</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
<tbody>{rows}</tbody>
</table>
</div>
)
);
}
export { ModerationPendingW2lTemplatesFc };
@@ -1,14 +1,18 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { SessionWrapper } from '@storyteller/components/src/session/SessionWrapper';
import { Gravatar } from '@storyteller/components/src/elements/Gravatar';
import { GetUserList, GetUserListIsOk, UserForList } from '@storyteller/components/src/api/moderation/user/GetUserList';
import { formatDistance } from 'date-fns';
import { WebUrl } from '../../../../../common/WebUrl';
import { BackLink } from '../../../_common/BackLink';
import React, { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
import { Gravatar } from "@storyteller/components/src/elements/Gravatar";
import {
GetUserList,
GetUserListIsOk,
UserForList,
} from "@storyteller/components/src/api/moderation/user/GetUserList";
import { formatDistance } from "date-fns";
import { WebUrl } from "../../../../../common/WebUrl";
import { BackLink } from "../../../_common/BackLink";
interface Props {
sessionWrapper: SessionWrapper,
sessionWrapper: SessionWrapper;
}
function ModerationUserListFc(props: Props) {
@@ -20,16 +24,14 @@ function ModerationUserListFc(props: Props) {
if (GetUserListIsOk(response)) {
setUserList(response.users);
}
}, []);
useEffect(() => {
getUsers();
}, [getUsers]);
if (!userList) {
return <div />
return <div />;
}
if (!props.sessionWrapper.canBanUsers()) {
@@ -37,21 +39,29 @@ function ModerationUserListFc(props: Props) {
}
const now = new Date();
let rows : Array<JSX.Element> = [];
let rows: Array<JSX.Element> = [];
userList.forEach(user => {
userList.forEach((user) => {
const createTime = new Date(user.created_at);
const relativeCreateTime = formatDistance(createTime, now, { addSuffix: true });
const relativeCreateTime = formatDistance(createTime, now, {
addSuffix: true,
});
const updateTime = new Date(user.updated_at);
const relativeUpdateTime = formatDistance(updateTime, now, { addSuffix: true });
const relativeUpdateTime = formatDistance(updateTime, now, {
addSuffix: true,
});
rows.push(
<tr key={user.user_token}>
<td>{user.user_id}</td>
<td>
<Link to={WebUrl.userProfilePage(user.display_name)}>
<Gravatar username={user.display_name} email_hash={user.gravatar_hash} size={12} />
<Gravatar
username={user.display_name}
email_hash={user.gravatar_hash}
size={12}
/>
&nbsp;
{user.display_name}
</Link>
@@ -61,12 +71,12 @@ function ModerationUserListFc(props: Props) {
<td>{user.user_role_slug}</td>
<td>{user.is_banned ? "banned" : ""}</td>
</tr>
)
);
});
return (
<div>
<h1 className="title is-1"> User list </h1>
<div className="container pt-5">
<h1 className="fw-bold"> User list </h1>
<BackLink link={WebUrl.moderationMain()} text="Back to moderation" />
@@ -74,14 +84,13 @@ function ModerationUserListFc(props: Props) {
<br />
<p>
Brandon did the <strong>bare minimum</strong> to get this working.
It isn't paginated and will break once we get a lot of users.
It's also not sortable.
I'll need to cycle back and fix it after we launch.
Brandon did the <strong>bare minimum</strong> to get this working. It
isn't paginated and will break once we get a lot of users. It's also not
sortable. I'll need to cycle back and fix it after we launch.
</p>
<br />
<table className="table">
<thead>
<tr>
@@ -93,12 +102,10 @@ function ModerationUserListFc(props: Props) {
<th>Is Banned?</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
<tbody>{rows}</tbody>
</table>
</div>
)
);
}
export { ModerationUserListFc };
@@ -1,11 +1,14 @@
import React, { useCallback, useEffect, useState } from 'react';
import { SessionWrapper } from '@storyteller/components/src/session/SessionWrapper';
import { WebUrl } from '../../../../../common/WebUrl';
import { BackLink } from '../../../_common/BackLink';
import { GetVoiceInventoryStats, GetVoiceInventoryStatsIsOk } from '@storyteller/components/src/api/moderation/stats/GetVoiceInventoryStats';
import React, { useCallback, useEffect, useState } from "react";
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
import { WebUrl } from "../../../../../common/WebUrl";
import { BackLink } from "../../../_common/BackLink";
import {
GetVoiceInventoryStats,
GetVoiceInventoryStatsIsOk,
} from "@storyteller/components/src/api/moderation/stats/GetVoiceInventoryStats";
interface Props {
sessionWrapper: SessionWrapper,
sessionWrapper: SessionWrapper;
}
function ModerationVoiceStatsFc(props: Props) {
@@ -19,7 +22,7 @@ function ModerationVoiceStatsFc(props: Props) {
setPublicVoicesCount(response.public_voices_count);
}
}, []);
const reloadStats = useCallback(async () => {
getVoiceStats();
}, [getVoiceStats]);
@@ -28,9 +31,8 @@ function ModerationVoiceStatsFc(props: Props) {
reloadStats();
}, [reloadStats]);
if (allVoicesCount === -1 && publicVoicesCount === -1) {
return <div />
return <div />;
}
if (!props.sessionWrapper.canEditOtherUsersTtsModels()) {
@@ -38,38 +40,33 @@ function ModerationVoiceStatsFc(props: Props) {
}
return (
<div>
<h1 className="title is-1"> Voice Stats </h1>
<div className="container pt-5">
<h1 className="fw-bold"> Voice Stats </h1>
<BackLink link={WebUrl.moderationMain()} text="Back to moderation" />
<br />
<br />
<table className="table is-fullwidth">
<tbody>
<tr>
<th>
Public voice vount
</th>
<th>Public voice vount</th>
<td>{publicVoicesCount} voices </td>
</tr>
<tr>
<th>
All voice count (incl banned)
</th>
<th>All voice count (incl banned)</th>
<td>{allVoicesCount} voices </td>
</tr>
</tbody>
</table>
<br />
<button
className="button is-info is-large is-fullwidth"
onClick={() => reloadStats()}
>Reload</button>
<button className="btn btn-primary w-100" onClick={() => reloadStats()}>
Reload
</button>
</div>
)
);
}
export { ModerationVoiceStatsFc };