feat: Templates (#1399)
* Migrations * New from template * fix: Don't allow public share of template * chore: Template badges * fix: Collection active * feat: New doc button on template list item * feat: New template menu * fix: Sorting * feat: Templates onboarding notice * fix: New doc button showing on archived/deleted templates
This commit is contained in:
@@ -29,6 +29,7 @@ class Archive extends React.Component<Props> {
|
||||
heading={<Subheading>Documents</Subheading>}
|
||||
empty={<Empty>The document archive is empty at the moment.</Empty>}
|
||||
showCollection
|
||||
showTemplate
|
||||
/>
|
||||
</CenteredContent>
|
||||
);
|
||||
|
||||
@@ -46,7 +46,7 @@ const MemberListItem = ({
|
||||
"Never signed in"
|
||||
)}
|
||||
{!user.lastActiveAt && <Badge>Invited</Badge>}
|
||||
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
|
||||
</React.Fragment>
|
||||
}
|
||||
image={<Avatar src={user.avatarUrl} size={40} />}
|
||||
|
||||
@@ -29,7 +29,7 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
|
||||
"Never signed in"
|
||||
)}
|
||||
{!user.lastActiveAt && <Badge>Invited</Badge>}
|
||||
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
|
||||
</React.Fragment>
|
||||
}
|
||||
actions={
|
||||
|
||||
@@ -8,12 +8,13 @@ import { observer, inject } from "mobx-react";
|
||||
import { Prompt, Route, withRouter } from "react-router-dom";
|
||||
import type { Location, RouterHistory } from "react-router-dom";
|
||||
import keydown from "react-keydown";
|
||||
import { InputIcon } from "outline-icons";
|
||||
import Flex from "components/Flex";
|
||||
import {
|
||||
collectionUrl,
|
||||
documentMoveUrl,
|
||||
documentHistoryUrl,
|
||||
documentEditUrl,
|
||||
editDocumentUrl,
|
||||
documentUrl,
|
||||
} from "utils/routeHelpers";
|
||||
import { emojiToUrl } from "utils/emoji";
|
||||
@@ -68,8 +69,6 @@ type Props = {
|
||||
@observer
|
||||
class DocumentScene extends React.Component<Props> {
|
||||
@observable editor: ?any;
|
||||
getEditorText: () => string = () => this.props.document.text;
|
||||
|
||||
@observable editorComponent = EditorImport;
|
||||
@observable isUploading: boolean = false;
|
||||
@observable isSaving: boolean = false;
|
||||
@@ -79,6 +78,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
@observable moveModalOpen: boolean = false;
|
||||
@observable lastRevision: number;
|
||||
@observable title: string;
|
||||
getEditorText: () => string = () => this.props.document.text;
|
||||
|
||||
constructor(props) {
|
||||
super();
|
||||
@@ -114,6 +114,12 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
if (document.injectTemplate) {
|
||||
this.isDirty = true;
|
||||
this.title = document.title;
|
||||
document.injectTemplate = false;
|
||||
}
|
||||
|
||||
this.updateBackground();
|
||||
}
|
||||
|
||||
@@ -143,7 +149,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
const { document, abilities } = this.props;
|
||||
|
||||
if (abilities.update) {
|
||||
this.props.history.push(documentEditUrl(document));
|
||||
this.props.history.push(editDocumentUrl(document));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +262,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
this.props.history.push(savedDocument.url);
|
||||
this.props.ui.setActiveDocument(savedDocument);
|
||||
} else if (isNew) {
|
||||
this.props.history.push(documentEditUrl(savedDocument));
|
||||
this.props.history.push(editDocumentUrl(savedDocument));
|
||||
this.props.ui.setActiveDocument(savedDocument);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -342,6 +348,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const value = revision ? revision.text : document.text;
|
||||
const injectTemplate = document.injectTemplate;
|
||||
const disableEmbeds =
|
||||
(team && team.documentEmbeds === false) || document.embedsDisabled;
|
||||
|
||||
@@ -360,7 +367,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
)}
|
||||
/>
|
||||
<PageTitle
|
||||
title={document.title.replace(document.emoji, "") || "Untitled"}
|
||||
title={document.titleWithDefault.replace(document.emoji, "")}
|
||||
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
|
||||
/>
|
||||
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
|
||||
@@ -400,6 +407,15 @@ class DocumentScene extends React.Component<Props> {
|
||||
column
|
||||
auto
|
||||
>
|
||||
{document.isTemplate &&
|
||||
!readOnly && (
|
||||
<Notice muted>
|
||||
You’re editing a template. Highlight some text and use the{" "}
|
||||
<PlaceholderIcon color="currentColor" /> control to add
|
||||
placeholders that can be filled out when creating new
|
||||
documents from this template.
|
||||
</Notice>
|
||||
)}
|
||||
{document.archivedAt &&
|
||||
!document.deletedAt && (
|
||||
<Notice muted>
|
||||
@@ -414,7 +430,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
{document.permanentlyDeletedAt && (
|
||||
<React.Fragment>
|
||||
<br />
|
||||
This document will be permanently deleted in{" "}
|
||||
This {document.noun} will be permanently deleted in{" "}
|
||||
<Time dateTime={document.permanentlyDeletedAt} /> unless
|
||||
restored.
|
||||
</React.Fragment>
|
||||
@@ -437,7 +453,8 @@ class DocumentScene extends React.Component<Props> {
|
||||
}}
|
||||
isShare={isShare}
|
||||
isDraft={document.isDraft}
|
||||
key={disableEmbeds ? "embeds-disabled" : "embeds-enabled"}
|
||||
template={document.isTemplate}
|
||||
key={[injectTemplate, disableEmbeds].join("-")}
|
||||
title={revision ? revision.title : this.title}
|
||||
document={document}
|
||||
value={readOnly ? value : undefined}
|
||||
@@ -476,6 +493,11 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
const PlaceholderIcon = styled(InputIcon)`
|
||||
position: relative;
|
||||
top: 6px;
|
||||
`;
|
||||
|
||||
const Background = styled(Container)`
|
||||
background: ${props => props.theme.background};
|
||||
transition: ${props => props.theme.backgroundTransition};
|
||||
|
||||
@@ -80,8 +80,8 @@ class DocumentEditor extends React.Component<Props> {
|
||||
type="text"
|
||||
onChange={onChangeTitle}
|
||||
onKeyDown={this.handleTitleKeyDown}
|
||||
placeholder="Start with a title…"
|
||||
value={!title && readOnly ? "Untitled" : title}
|
||||
placeholder={document.placeholder}
|
||||
value={!title && readOnly ? document.titleWithDefault : title}
|
||||
style={startsWithEmojiAndSpace ? { marginLeft: "-1.2em" } : undefined}
|
||||
readOnlyWriteCheckboxes
|
||||
readOnly={readOnly}
|
||||
|
||||
@@ -15,11 +15,12 @@ import {
|
||||
import { transparentize, darken } from "polished";
|
||||
import Document from "models/Document";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import { documentEditUrl } from "utils/routeHelpers";
|
||||
import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers";
|
||||
import { meta } from "utils/keyboard";
|
||||
|
||||
import Flex from "components/Flex";
|
||||
import Breadcrumb, { Slash } from "components/Breadcrumb";
|
||||
import TemplatesMenu from "menus/TemplatesMenu";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
|
||||
import DocumentShare from "scenes/DocumentShare";
|
||||
@@ -76,7 +77,15 @@ class Header extends React.Component<Props> {
|
||||
handleScroll = throttle(this.updateIsScrolled, 50);
|
||||
|
||||
handleEdit = () => {
|
||||
this.redirectTo = documentEditUrl(this.props.document);
|
||||
this.redirectTo = editDocumentUrl(this.props.document);
|
||||
};
|
||||
|
||||
handleNewFromTemplate = () => {
|
||||
const { document } = this.props;
|
||||
|
||||
this.redirectTo = newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
});
|
||||
};
|
||||
|
||||
handleSave = () => {
|
||||
@@ -125,6 +134,8 @@ class Header extends React.Component<Props> {
|
||||
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
const isPubliclyShared = share && share.published;
|
||||
const isNew = document.isNew;
|
||||
const isTemplate = document.isTemplate;
|
||||
const can = policies.abilities(document.id);
|
||||
const canShareDocuments = auth.team && auth.team.sharing && can.share;
|
||||
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
|
||||
@@ -196,6 +207,13 @@ class Header extends React.Component<Props> {
|
||||
currentUserId={auth.user ? auth.user.id : undefined}
|
||||
/>
|
||||
</Fade>
|
||||
{isEditing &&
|
||||
!isTemplate &&
|
||||
isNew && (
|
||||
<Action>
|
||||
<TemplatesMenu document={document} />
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing &&
|
||||
canShareDocuments && (
|
||||
<Action>
|
||||
@@ -245,31 +263,10 @@ class Header extends React.Component<Props> {
|
||||
</Action>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{can.update &&
|
||||
isDraft &&
|
||||
!isRevision && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip="Publish"
|
||||
shortcut={`${meta}+shift+p`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={this.handlePublish}
|
||||
title="Publish document"
|
||||
disabled={publishingIsDisabled}
|
||||
small
|
||||
>
|
||||
{isPublishing ? "Publishing…" : "Publish"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip="Edit document"
|
||||
tooltip={`Edit ${document.noun}`}
|
||||
shortcut="e"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
@@ -305,6 +302,42 @@ class Header extends React.Component<Props> {
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
{canEdit &&
|
||||
isTemplate &&
|
||||
!isDraft &&
|
||||
!isRevision && (
|
||||
<Action>
|
||||
<Button
|
||||
icon={<PlusIcon />}
|
||||
onClick={this.handleNewFromTemplate}
|
||||
primary
|
||||
small
|
||||
>
|
||||
New from template
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
{can.update &&
|
||||
isDraft &&
|
||||
!isRevision && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip="Publish"
|
||||
shortcut={`${meta}+shift+p`}
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={this.handlePublish}
|
||||
title="Publish document"
|
||||
disabled={publishingIsDisabled}
|
||||
small
|
||||
>
|
||||
{isPublishing ? "Publishing…" : "Publish"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing && (
|
||||
<React.Fragment>
|
||||
<Separator />
|
||||
|
||||
@@ -50,14 +50,16 @@ class DocumentDelete extends React.Component<Props> {
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
Are you sure about that? Deleting the{" "}
|
||||
<strong>{document.title}</strong> document will delete all of its
|
||||
history, and any nested documents.
|
||||
<strong>{document.titleWithDefault}</strong> {document.noun} will
|
||||
delete all of its history{document.isTemplate
|
||||
? ""
|
||||
: ", and any nested documents"}.
|
||||
</HelpText>
|
||||
{!document.isDraft &&
|
||||
!document.isArchived && (
|
||||
<HelpText>
|
||||
If you’d like the option of referencing or restoring this
|
||||
document in the future, consider archiving it instead.
|
||||
If you’d like the option of referencing or restoring this{" "}
|
||||
{document.noun} in the future, consider archiving it instead.
|
||||
</HelpText>
|
||||
)}
|
||||
<Button type="submit" danger>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { inject } from "mobx-react";
|
||||
import queryString from "query-string";
|
||||
import type { RouterHistory, Location } from "react-router-dom";
|
||||
import Flex from "components/Flex";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import LoadingPlaceholder from "components/LoadingPlaceholder";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import { documentEditUrl } from "utils/routeHelpers";
|
||||
import { editDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
@@ -19,16 +20,18 @@ type Props = {
|
||||
|
||||
class DocumentNew extends React.Component<Props> {
|
||||
async componentDidMount() {
|
||||
const params = queryString.parse(this.props.location.search);
|
||||
|
||||
try {
|
||||
const document = await this.props.documents.create({
|
||||
collectionId: this.props.match.params.id,
|
||||
parentDocumentId: new URLSearchParams(this.props.location.search).get(
|
||||
"parentDocumentId"
|
||||
),
|
||||
parentDocumentId: params.parentDocumentId,
|
||||
templateId: params.templateId,
|
||||
template: params.template,
|
||||
title: "",
|
||||
text: "",
|
||||
});
|
||||
this.props.history.replace(documentEditUrl(document));
|
||||
this.props.history.replace(editDocumentUrl(document));
|
||||
} catch (err) {
|
||||
this.props.ui.showToast("Couldn’t create the document, try again?");
|
||||
this.props.history.goBack();
|
||||
|
||||
@@ -64,19 +64,21 @@ class DocumentShare extends React.Component<Props> {
|
||||
const { document, policies, shares, onSubmit } = this.props;
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
const can = policies.abilities(share ? share.id : "");
|
||||
const canPublish = can.update && !document.isTemplate;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<HelpText>
|
||||
The link below provides a read-only version of the document{" "}
|
||||
<strong>{document.title}</strong>.{" "}
|
||||
{can.update &&
|
||||
"You can optionally make it accessible to anyone with the link."}{" "}
|
||||
<strong>{document.titleWithDefault}</strong>.{" "}
|
||||
{canPublish
|
||||
? "You can optionally make it accessible to anyone with the link."
|
||||
: "It is only viewable by those that already have access to the collection."}{" "}
|
||||
<Link to="/settings/shares" onClick={onSubmit}>
|
||||
Manage all share links
|
||||
</Link>.
|
||||
</HelpText>
|
||||
{can.update && (
|
||||
{canPublish && (
|
||||
<React.Fragment>
|
||||
<Switch
|
||||
id="published"
|
||||
|
||||
61
app/scenes/DocumentTemplatize.js
Normal file
61
app/scenes/DocumentTemplatize.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import { documentUrl } from "utils/routeHelpers";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import Document from "models/Document";
|
||||
import UiStore from "stores/UiStore";
|
||||
|
||||
type Props = {
|
||||
ui: UiStore,
|
||||
document: Document,
|
||||
history: RouterHistory,
|
||||
onSubmit: () => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
class DocumentTemplatize extends React.Component<Props> {
|
||||
@observable isSaving: boolean;
|
||||
|
||||
handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
const template = await this.props.document.templatize();
|
||||
this.props.history.push(documentUrl(template));
|
||||
this.props.ui.showToast("Template created, go ahead and customize it");
|
||||
this.props.onSubmit();
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { document } = this.props;
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
Creating a template from{" "}
|
||||
<strong>{document.titleWithDefault}</strong> is a non-destructive
|
||||
action – we'll make a copy of the document and turn it into a
|
||||
template that can be used as a starting point for new documents.
|
||||
</HelpText>
|
||||
<Button type="submit">
|
||||
{this.isSaving ? "Creating…" : "Create template"}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("ui")(withRouter(DocumentTemplatize));
|
||||
@@ -36,7 +36,7 @@ const GroupMemberListItem = ({
|
||||
"Never signed in"
|
||||
)}
|
||||
{!user.lastActiveAt && <Badge>Invited</Badge>}
|
||||
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
|
||||
</React.Fragment>
|
||||
}
|
||||
image={<Avatar src={user.avatarUrl} size={40} />}
|
||||
|
||||
@@ -29,7 +29,7 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
|
||||
"Never signed in"
|
||||
)}
|
||||
{!user.lastActiveAt && <Badge>Invited</Badge>}
|
||||
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
|
||||
</React.Fragment>
|
||||
}
|
||||
actions={
|
||||
|
||||
@@ -143,10 +143,29 @@ const description = event => {
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
if (event.name === "documents.create") {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{capitalize(event.verbPastTense)} the{" "}
|
||||
<Link to={`/doc/${event.documentId}`}>
|
||||
{event.data.title || "Untitled"}
|
||||
</Link>{" "}
|
||||
document{" "}
|
||||
{event.data.templateId && (
|
||||
<React.Fragment>
|
||||
from a <Link to={`/doc/${event.data.templateId}`}>template</Link>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
{capitalize(event.verbPastTense)} the{" "}
|
||||
<Link to={`/doc/${event.documentId}`}>{event.data.title}</Link> document
|
||||
<Link to={`/doc/${event.documentId}`}>
|
||||
{event.data.title || "Untitled"}
|
||||
</Link>{" "}
|
||||
document
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ class UserListItem extends React.Component<Props> {
|
||||
) : (
|
||||
"Invited"
|
||||
)}
|
||||
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
|
||||
{user.isSuspended && <Badge>Suspended</Badge>}
|
||||
</React.Fragment>
|
||||
}
|
||||
|
||||
70
app/scenes/Templates.js
Normal file
70
app/scenes/Templates.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { observer, inject } from "mobx-react";
|
||||
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Empty from "components/Empty";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import Heading from "components/Heading";
|
||||
import PaginatedDocumentList from "components/PaginatedDocumentList";
|
||||
import Tabs from "components/Tabs";
|
||||
import Tab from "components/Tab";
|
||||
import NewTemplateMenu from "menus/NewTemplateMenu";
|
||||
import Actions, { Action } from "components/Actions";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
|
||||
type Props = {
|
||||
documents: DocumentsStore,
|
||||
match: Object,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Templates extends React.Component<Props> {
|
||||
render() {
|
||||
const {
|
||||
fetchTemplates,
|
||||
templates,
|
||||
templatesAlphabetical,
|
||||
} = this.props.documents;
|
||||
const { sort } = this.props.match.params;
|
||||
|
||||
return (
|
||||
<CenteredContent column auto>
|
||||
<PageTitle title="Templates" />
|
||||
<Heading>Templates</Heading>
|
||||
<PaginatedDocumentList
|
||||
heading={
|
||||
<Tabs>
|
||||
<Tab to="/templates" exact>
|
||||
Recently Updated
|
||||
</Tab>
|
||||
<Tab to="/templates/alphabetical" exact>
|
||||
Alphabetical
|
||||
</Tab>
|
||||
</Tabs>
|
||||
}
|
||||
empty={
|
||||
<Empty>
|
||||
There are no templates just yet. You can create templates to help
|
||||
your team create consistent and accurate documentation.
|
||||
</Empty>
|
||||
}
|
||||
fetch={fetchTemplates}
|
||||
documents={
|
||||
sort === "alphabetical" ? templatesAlphabetical : templates
|
||||
}
|
||||
showCollection
|
||||
showDraft
|
||||
/>
|
||||
|
||||
<Actions align="center" justify="flex-end">
|
||||
<Action>
|
||||
<NewTemplateMenu />
|
||||
</Action>
|
||||
</Actions>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("documents")(Templates);
|
||||
@@ -29,6 +29,7 @@ class Trash extends React.Component<Props> {
|
||||
heading={<Subheading>Documents</Subheading>}
|
||||
empty={<Empty>Trash is empty at the moment.</Empty>}
|
||||
showCollection
|
||||
showTemplate
|
||||
/>
|
||||
</CenteredContent>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user