mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-13 05:12:50 +00:00
Api2.6/ upload animations expressions (#511)
Co-authored-by: wil <wil@storyteller.ai>
This commit is contained in:
1 parent
1b5f6356e2
commit
122f39bd25
16 files changed
+99
-227
No files matched your search
@@ -137,7 +137,7 @@ export class ApiManager {
|
||||
endpoint: string;
|
||||
formRecord: Record<string, string>;
|
||||
uuid: string;
|
||||
blob?: Blob;
|
||||
blob?: Blob | File;
|
||||
blobFileName?: string;
|
||||
}): Promise<T> {
|
||||
const formData = new FormData();
|
||||
@@ -150,10 +150,6 @@ export class ApiManager {
|
||||
} else if (blob) {
|
||||
formData.append("file", blob);
|
||||
}
|
||||
// return this.fetch<FormData, T>(endpoint, {
|
||||
// method: "POST",
|
||||
// body: formData,
|
||||
// });
|
||||
return this.fetchMultipartFormData<T>(endpoint, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiManager, ApiResponse } from "./ApiManager";
|
||||
import { Visibility } from "~/enums";
|
||||
import { FilterEngineCategories, Visibility } from "~/enums";
|
||||
|
||||
export class MediaUploadApi extends ApiManager {
|
||||
private async Upload({
|
||||
@@ -10,7 +10,7 @@ export class MediaUploadApi extends ApiManager {
|
||||
options,
|
||||
}: {
|
||||
endpoint: string;
|
||||
blob: Blob;
|
||||
blob: Blob | File;
|
||||
fileName: string;
|
||||
uuid: string;
|
||||
options: Record<string, string | number | undefined>;
|
||||
@@ -87,17 +87,19 @@ export class MediaUploadApi extends ApiManager {
|
||||
}
|
||||
|
||||
public async UploadNewEngineAsset({
|
||||
blob,
|
||||
file,
|
||||
fileName,
|
||||
uuid,
|
||||
engine_category,
|
||||
maybe_animation_type,
|
||||
maybe_duration_millis,
|
||||
maybe_title,
|
||||
maybe_visibility = Visibility.Public,
|
||||
}: {
|
||||
blob: Blob;
|
||||
file: File;
|
||||
fileName: string;
|
||||
uuid: string;
|
||||
engine_category: FilterEngineCategories;
|
||||
maybe_animation_type?: string;
|
||||
maybe_duration_millis?: number;
|
||||
maybe_title?: string;
|
||||
@@ -105,12 +107,13 @@ export class MediaUploadApi extends ApiManager {
|
||||
}): Promise<ApiResponse<string>> {
|
||||
const endpoint = `${this.ApiTargets.BaseApi}/v1/media_files/upload/new_engine_asset`;
|
||||
const options: Record<string, string | number | undefined> = {
|
||||
engine_category,
|
||||
maybe_title,
|
||||
maybe_visibility: maybe_visibility?.toString(),
|
||||
maybe_animation_type,
|
||||
maybe_duration_millis,
|
||||
};
|
||||
return this.Upload({ endpoint, blob, fileName, uuid, options });
|
||||
return this.Upload({ endpoint, blob: file, fileName, uuid, options });
|
||||
}
|
||||
|
||||
public async UploadNewScene({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faFileArrowUp, faFileAudio } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { P } from "~/components";
|
||||
import { getFileName } from "./utils/getFileName";
|
||||
import { getFileName } from "~/utilities";
|
||||
|
||||
interface Props {
|
||||
file: File | null;
|
||||
|
||||
@@ -14,21 +14,13 @@ export const UploadAssetError = ({
|
||||
return (
|
||||
<>
|
||||
{isAssetError
|
||||
? "There was a problem uploading your object."
|
||||
: "There was a problem creating art for your object"}
|
||||
? "There was a problem uploading your file."
|
||||
: "There was a problem uploading the thumbnail for your file."}
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button
|
||||
{...{
|
||||
onClick: onCancel,
|
||||
variant: "secondary",
|
||||
}}>
|
||||
<Button onClick={onCancel} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
{...{
|
||||
onClick: onRetry,
|
||||
variant: "primary",
|
||||
}}>
|
||||
<Button onClick={onRetry} variant="primary">
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,59 +1,20 @@
|
||||
import { FileUploader } from "react-drag-drop-files";
|
||||
import { Button } from "~/components";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { DragAndDropZone } from "./DragAndDropZone";
|
||||
import { UploadMediaResponse } from "~/components/signaled/UploadAudioButtonDialogue/utilities";
|
||||
import { useState } from "react";
|
||||
|
||||
enum UploadState {
|
||||
"init",
|
||||
"staged",
|
||||
"uploading",
|
||||
"uploaded",
|
||||
"error",
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
fileTypes: string[];
|
||||
setToken?: (token: string) => void;
|
||||
uploadFile?: (file: File) => Promise<UploadMediaResponse>;
|
||||
file: File | null;
|
||||
setFile: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const UploadFile = ({
|
||||
fileTypes,
|
||||
title,
|
||||
setToken,
|
||||
uploadFile,
|
||||
file,
|
||||
setFile,
|
||||
}: Props) => {
|
||||
const [uploadState, setUploadState] = useState<UploadState>(UploadState.init);
|
||||
|
||||
const submitUpload = (file: File | null) => {
|
||||
if (file && uploadFile) {
|
||||
setUploadState(UploadState.uploading);
|
||||
uploadFile(file).then((res: UploadMediaResponse) => {
|
||||
if ("media_file_token" in res) {
|
||||
setUploadState(UploadState.uploaded);
|
||||
setToken && setToken(res.media_file_token);
|
||||
} else {
|
||||
setUploadState(UploadState.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const UploadFile = ({ fileTypes, title, file, setFile }: Props) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
{title}
|
||||
<FileUploader
|
||||
handleChange={(file: File | null) => {
|
||||
setFile(file);
|
||||
setUploadState(file ? UploadState.staged : UploadState.init);
|
||||
}}
|
||||
name="file"
|
||||
types={fileTypes}
|
||||
@@ -61,19 +22,6 @@ export const UploadFile = ({
|
||||
>
|
||||
<DragAndDropZone file={file} fileTypes={fileTypes} />
|
||||
</FileUploader>
|
||||
{!!uploadFile && (
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
onClick={() => submitUpload(file)}
|
||||
disabled={uploadState !== UploadState.staged}
|
||||
>
|
||||
Upload
|
||||
{uploadState === UploadState.uploading && (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} spin />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button, H6, Input, ListDropdown } from "~/components";
|
||||
import { UploadFile } from "./UploadFile";
|
||||
import { useState } from "react";
|
||||
import { MediaFileAnimationType } from "~/api/media_files/UploadNewEngineAsset";
|
||||
import { MediaFileAnimationType } from "~/enums";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Button } from "~/components";
|
||||
|
||||
interface Props {
|
||||
onCancel: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export const UploadLoaderError = ({ onCancel, onRetry }: Props) => {
|
||||
return (
|
||||
<>
|
||||
There was a problem loading your object. Try another.
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button
|
||||
{...{
|
||||
onClick: onCancel,
|
||||
variant: "secondary",
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
{...{
|
||||
onClick: onRetry,
|
||||
variant: "primary",
|
||||
}}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
.obj-uploader-modal-load-view {
|
||||
.uploader-dots {
|
||||
background: transparent;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.uploader-message {
|
||||
width: 100%;
|
||||
opacity: 0.5;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.object-preview-canvas {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1,28 +1,23 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { TransitionDialogue, LoadingDots } from "~/components";
|
||||
import "./UploadModal.scss";
|
||||
import { UploadLoaderError } from "./UploadLoaderError";
|
||||
|
||||
import { UploadAssetError } from "./UploadAssetError";
|
||||
import { UploadSuccess } from "./UploadSuccess";
|
||||
import { UploadFiles } from "./UploadFiles";
|
||||
import {
|
||||
MediaFileAnimationType,
|
||||
MediaFileEngineCategory,
|
||||
UploadNewEngineAsset,
|
||||
} from "~/api/media_files/UploadNewEngineAsset";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { UploadMedia } from "~/api/media_files/UploadMedia";
|
||||
import { EditCoverImage } from "~/api/media_files/EditCoverImage";
|
||||
import { MediaFilesApi, MediaUploadApi } from "~/Classes/ApiManager";
|
||||
import { FilterEngineCategories, MediaFileAnimationType } from "~/enums";
|
||||
import { getFileName } from "~/utilities";
|
||||
|
||||
interface Props {
|
||||
closeModal: () => void;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
fileTypes: string[];
|
||||
typeOptions: { [key: string]: string }[];
|
||||
type: MediaFileEngineCategory;
|
||||
type: FilterEngineCategories;
|
||||
}
|
||||
|
||||
enum UploaderState {
|
||||
@@ -34,11 +29,9 @@ enum UploaderState {
|
||||
assetError,
|
||||
coverCreateError,
|
||||
coverSetError,
|
||||
loaderError,
|
||||
}
|
||||
|
||||
export function UploadModalMovement({
|
||||
closeModal,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
@@ -49,9 +42,15 @@ export function UploadModalMovement({
|
||||
}: Props) {
|
||||
const [objUploadStatus, setObjUploadStatus] = useState(UploaderState.ready);
|
||||
|
||||
const resetModalState = useCallback(() => {
|
||||
const resetModalState = () => {
|
||||
setObjUploadStatus(UploaderState.ready);
|
||||
}, []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
resetModalState();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const onSubmit = async ({
|
||||
title,
|
||||
@@ -66,54 +65,62 @@ export function UploadModalMovement({
|
||||
length: number;
|
||||
thumbnailFile: File | null;
|
||||
}) => {
|
||||
const res = await UploadNewEngineAsset({
|
||||
engine_category: type,
|
||||
setObjUploadStatus(UploaderState.uploadingAsset);
|
||||
const mediaUploadApi = new MediaUploadApi();
|
||||
const assetReponse = await mediaUploadApi.UploadNewEngineAsset({
|
||||
file: assetFile,
|
||||
maybe_animation_type: typeOption as MediaFileAnimationType,
|
||||
fileName: assetFile.name,
|
||||
engine_category: type,
|
||||
maybe_animation_type: typeOption,
|
||||
maybe_duration_millis: length,
|
||||
maybe_title: title,
|
||||
uuid_idempotency_token: uuidv4(),
|
||||
uuid: uuidv4(),
|
||||
});
|
||||
if (!("media_file_token" in res)) {
|
||||
|
||||
if (!assetReponse.success || !assetReponse.data) {
|
||||
setObjUploadStatus(UploaderState.assetError);
|
||||
return;
|
||||
}
|
||||
const assetToken = res.media_file_token;
|
||||
const assetToken = assetReponse.data;
|
||||
if (!thumbnailFile) {
|
||||
setObjUploadStatus(UploaderState.success);
|
||||
return;
|
||||
}
|
||||
const resp = await UploadMedia({
|
||||
uuid_idempotency_token: uuidv4(),
|
||||
file: thumbnailFile,
|
||||
source: title,
|
||||
|
||||
setObjUploadStatus(UploaderState.uploadingCover);
|
||||
const thumbnailResponse = await mediaUploadApi.UploadImage({
|
||||
uuid: uuidv4(),
|
||||
blob: thumbnailFile,
|
||||
fileName: getFileName(thumbnailFile),
|
||||
maybe_title: "thumbnail_" + title,
|
||||
});
|
||||
if (!("media_file_token" in resp)) {
|
||||
setObjUploadStatus(UploaderState.uploadingCover);
|
||||
if (!thumbnailResponse.success || !thumbnailResponse.data) {
|
||||
setObjUploadStatus(UploaderState.coverCreateError);
|
||||
return;
|
||||
}
|
||||
const thumbnailToken = resp.media_file_token;
|
||||
const editRes = await EditCoverImage(assetToken, {
|
||||
cover_image_media_file_token: thumbnailToken,
|
||||
|
||||
setObjUploadStatus(UploaderState.settingCover);
|
||||
const thumbnailToken = thumbnailResponse.data;
|
||||
const mediaFilesApi = new MediaFilesApi();
|
||||
const setThumbnailResponse = await mediaFilesApi.UpdateCoverImage({
|
||||
mediaFileToken: assetToken,
|
||||
imageToken: thumbnailToken,
|
||||
});
|
||||
if (!editRes.success) {
|
||||
setObjUploadStatus(UploaderState.uploadingCover);
|
||||
if (!setThumbnailResponse.success) {
|
||||
setObjUploadStatus(UploaderState.coverSetError);
|
||||
return;
|
||||
}
|
||||
setObjUploadStatus(UploaderState.success);
|
||||
};
|
||||
|
||||
const objUploaderContent = () => {
|
||||
const ObjUploaderContent = () => {
|
||||
switch (objUploadStatus) {
|
||||
case UploaderState.ready:
|
||||
return (
|
||||
<UploadFiles
|
||||
title={title}
|
||||
fileTypes={fileTypes}
|
||||
onClose={() => {
|
||||
closeModal();
|
||||
onClose();
|
||||
}}
|
||||
onClose={onClose}
|
||||
typeOptions={typeOptions}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
@@ -122,20 +129,18 @@ export function UploadModalMovement({
|
||||
case UploaderState.uploadingCover:
|
||||
case UploaderState.settingCover:
|
||||
return (
|
||||
<div {...{ className: "obj-uploader-modal-load-view" }}>
|
||||
<LoadingDots {...{ className: "uploader-dots" }} />
|
||||
<div {...{ className: "uploader-message" }}>Uploading...</div>
|
||||
</div>
|
||||
<>
|
||||
<LoadingDots className="mb-1 bg-transparent" />
|
||||
<div className="w-100 text-center opacity-50">Uploading...</div>
|
||||
</>
|
||||
);
|
||||
case UploaderState.success:
|
||||
return (
|
||||
<UploadSuccess
|
||||
title={title}
|
||||
onOk={() => {
|
||||
closeModal();
|
||||
onClose();
|
||||
onSuccess();
|
||||
setTimeout(() => setObjUploadStatus(UploaderState.ready), 0);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -144,50 +149,19 @@ export function UploadModalMovement({
|
||||
case UploaderState.coverSetError:
|
||||
return (
|
||||
<UploadAssetError
|
||||
onCancel={() => {
|
||||
closeModal();
|
||||
onClose();
|
||||
setTimeout(() => setObjUploadStatus(UploaderState.ready), 0);
|
||||
}}
|
||||
onCancel={onClose}
|
||||
onRetry={() => {
|
||||
switch (objUploadStatus) {
|
||||
case UploaderState.assetError: {
|
||||
break;
|
||||
}
|
||||
case UploaderState.coverCreateError: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}}
|
||||
isAssetError={objUploadStatus === UploaderState.assetError}
|
||||
/>
|
||||
);
|
||||
case UploaderState.loaderError:
|
||||
return (
|
||||
<UploadLoaderError
|
||||
onCancel={() => {
|
||||
closeModal();
|
||||
onClose();
|
||||
setTimeout(() => setObjUploadStatus(UploaderState.ready), 0);
|
||||
}}
|
||||
onRetry={() => {
|
||||
setObjUploadStatus(UploaderState.ready);
|
||||
resetModalState();
|
||||
}}
|
||||
isAssetError={objUploadStatus === UploaderState.assetError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TransitionDialogue
|
||||
{...{
|
||||
isOpen,
|
||||
onClose,
|
||||
title: title,
|
||||
}}
|
||||
>
|
||||
{objUploaderContent()}
|
||||
<TransitionDialogue isOpen={isOpen} onClose={onClose} title={title}>
|
||||
<ObjUploaderContent />
|
||||
</TransitionDialogue>
|
||||
);
|
||||
}
|
||||
@@ -7,20 +7,13 @@ interface Props {
|
||||
|
||||
export const UploadSuccess = ({ title, onOk }: Props) => {
|
||||
return (
|
||||
<div {...{ className: "obj-uploader-modal-success-view" }}>
|
||||
<div
|
||||
{...{
|
||||
className: "uploader-message",
|
||||
}}>{`Added ${title} to objects`}</div>
|
||||
<>
|
||||
<div className="w-100 text-center opacity-50">{`Added ${title} to your library`}</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button
|
||||
{...{
|
||||
onClick: onOk,
|
||||
variant: "primary",
|
||||
}}>
|
||||
<Button onClick={onOk} variant="primary">
|
||||
Ok
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export const getFileName = (file: File) => {
|
||||
return file.name.split(".")[0] || "";
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
export enum MediaFileAnimationType {
|
||||
ArKit = "ar_kit",
|
||||
MikuMikuDance = "miku_miku_dance",
|
||||
MikuMikuDanceArKit = "miku_miku_dance_ar_kit",
|
||||
Mixamo = "mixamo",
|
||||
MixamoArKit = "mixamo_ar_kit",
|
||||
MocapNet = "mocap_net",
|
||||
MocapNetArKit = "mocap_net_ar_kit",
|
||||
MoveAi = "move_ai",
|
||||
MoveAiArKit = "move_ai_ar_kit",
|
||||
Rigify = "rigify",
|
||||
RigifyArKit = "rigify_ar_kit",
|
||||
Rokoko = "rokoko",
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export * from "./ClipGroup";
|
||||
export * from "./ClipType";
|
||||
export * from "./FileTypes";
|
||||
export * from "./JobStatus";
|
||||
export * from "./MediaFileAnimationTypes";
|
||||
export * from "./QueryFilters";
|
||||
export * from "./toasts";
|
||||
export * from "./Visibility";
|
||||
@@ -8,18 +8,17 @@ import {
|
||||
AssetFilterOption,
|
||||
AssetType,
|
||||
FilterEngineCategories,
|
||||
MediaFileAnimationType,
|
||||
ToastTypes,
|
||||
} from "~/enums";
|
||||
|
||||
import { animationFilter, animationItems } from "~/pages/PageEnigma/signals";
|
||||
import { addToast } from "~/signals";
|
||||
|
||||
import { MediaFileAnimationType } from "~/api/media_files/UploadNewEngineAsset";
|
||||
import { MediaFileEngineCategory } from "~/api/media_files/UploadEngineAsset";
|
||||
|
||||
import { ItemElements } from "~/pages/PageEnigma/comps/SidePanelTabs/itemTabs/ItemElements";
|
||||
import { Button, UploadModalMovement } from "~/components";
|
||||
import { TabTitle } from "~/pages/PageEnigma/comps/SidePanelTabs/comps/TabTitle";
|
||||
|
||||
import { MediaFilesApi } from "~/Classes/ApiManager";
|
||||
|
||||
export const AnimationTab = () => {
|
||||
@@ -127,7 +126,6 @@ export const AnimationTab = () => {
|
||||
/>
|
||||
</div>
|
||||
<UploadModalMovement
|
||||
closeModal={() => setOpen(false)}
|
||||
onClose={() => setOpen(false)}
|
||||
onSuccess={refetchAnimations}
|
||||
isOpen={open}
|
||||
@@ -140,7 +138,7 @@ export const AnimationTab = () => {
|
||||
{ Rigify: MediaFileAnimationType.Rigify },
|
||||
{ Rokoko: MediaFileAnimationType.Rokoko },
|
||||
]}
|
||||
type={MediaFileEngineCategory.Animation}
|
||||
type={FilterEngineCategories.ANIMATION}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,11 +8,10 @@ import {
|
||||
AssetType,
|
||||
FilterEngineCategories,
|
||||
ToastTypes,
|
||||
MediaFileAnimationType,
|
||||
} from "~/enums";
|
||||
|
||||
import { expressionFilter } from "~/pages/PageEnigma/signals";
|
||||
import { MediaFileEngineCategory } from "~/api/media_files/UploadEngineAsset";
|
||||
import { MediaFileAnimationType } from "~/api/media_files/UploadNewEngineAsset";
|
||||
|
||||
import { ItemElements } from "~/pages/PageEnigma/comps/SidePanelTabs/itemTabs/ItemElements";
|
||||
import {
|
||||
@@ -170,7 +169,7 @@ export const ExpressionTab = () => {
|
||||
fileTypes={["CSV"]}
|
||||
title="Upload Expression"
|
||||
typeOptions={[{ ARKit: MediaFileAnimationType.ArKit }]}
|
||||
type={MediaFileEngineCategory.Expression}
|
||||
type={FilterEngineCategories.EXPRESSION}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+4
-1
@@ -31,7 +31,10 @@ export const isJobStatusTerminal = (curr: JobStatus) => {
|
||||
};
|
||||
|
||||
export const getFileName = (file: File) => {
|
||||
return file.name.split(".")[0] || "";
|
||||
return file.name.substring(0, file.name.lastIndexOf("."));
|
||||
};
|
||||
export const getFileExtension = (file: File) => {
|
||||
return file.name.substring(file.name.lastIndexOf("."));
|
||||
};
|
||||
|
||||
export const getFileTypesFromEnum = (enumOfTypes: object) => {
|
||||
|
||||
Reference in new issue
Block a user