mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 20:37:21 +00:00
optimize elements by reducing the bundle size required
This commit is contained in:
128
app/src/ui/elements/auth/AuthForm.tsx
Normal file
128
app/src/ui/elements/auth/AuthForm.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { ValueError } from "@sinclair/typebox/value";
|
||||
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
|
||||
import clsx from "clsx";
|
||||
import { type TSchema, Type, Value } from "core/utils";
|
||||
import { Form, type Validator } from "json-schema-form-react";
|
||||
import { transform } from "lodash-es";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Group, Input, Label } from "ui/components/form/Formy/components";
|
||||
import { SocialLink } from "./SocialLink";
|
||||
|
||||
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
|
||||
className?: string;
|
||||
formData?: any;
|
||||
action: "login" | "register";
|
||||
method?: "POST" | "GET";
|
||||
auth?: Partial<Pick<AppAuthSchema, "basepath" | "strategies">>;
|
||||
buttonLabel?: string;
|
||||
};
|
||||
|
||||
class TypeboxValidator implements Validator<ValueError> {
|
||||
async validate(schema: TSchema, data: any) {
|
||||
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
|
||||
}
|
||||
}
|
||||
|
||||
const validator = new TypeboxValidator();
|
||||
|
||||
const schema = Type.Object({
|
||||
email: Type.String({
|
||||
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
|
||||
}),
|
||||
password: Type.String({
|
||||
minLength: 8 // @todo: this should be configurable
|
||||
})
|
||||
});
|
||||
|
||||
export function AuthForm({
|
||||
formData,
|
||||
className,
|
||||
method = "POST",
|
||||
action,
|
||||
auth,
|
||||
buttonLabel = action === "login" ? "Sign in" : "Sign up",
|
||||
...props
|
||||
}: LoginFormProps) {
|
||||
const basepath = auth?.basepath ?? "/api/auth";
|
||||
const password = {
|
||||
action: `${basepath}/password/${action}`,
|
||||
strategy: auth?.strategies?.password ?? ({ type: "password" } as const)
|
||||
};
|
||||
|
||||
const oauth = transform(
|
||||
auth?.strategies ?? {},
|
||||
(result, value, key) => {
|
||||
if (value.type !== "password") {
|
||||
result[key] = value.config;
|
||||
}
|
||||
},
|
||||
{}
|
||||
) as Record<string, AppAuthOAuthStrategy>;
|
||||
const has_oauth = Object.keys(oauth).length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{has_oauth && (
|
||||
<>
|
||||
<div>
|
||||
{Object.entries(oauth)?.map(([name, oauth], key) => (
|
||||
<SocialLink
|
||||
provider={name}
|
||||
method={method}
|
||||
basepath={basepath}
|
||||
key={key}
|
||||
action={action}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Or />
|
||||
</>
|
||||
)}
|
||||
<Form
|
||||
method={method}
|
||||
action={password.action}
|
||||
{...props}
|
||||
schema={schema}
|
||||
validator={validator}
|
||||
validationMode="change"
|
||||
className={clsx("flex flex-col gap-3 w-full", className)}
|
||||
>
|
||||
{({ errors, submitting }) => (
|
||||
<>
|
||||
<Group>
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input type="email" name="email" />
|
||||
</Group>
|
||||
<Group>
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input type="password" name="password" />
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="large"
|
||||
className="w-full mt-2 justify-center"
|
||||
disabled={errors.length > 0 || submitting}
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Or = () => (
|
||||
<div className="w-full flex flex-row items-center">
|
||||
<div className="relative flex grow">
|
||||
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
|
||||
</div>
|
||||
<div className="mx-5">or</div>
|
||||
<div className="relative flex grow">
|
||||
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
33
app/src/ui/elements/auth/AuthScreen.tsx
Normal file
33
app/src/ui/elements/auth/AuthScreen.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useAuthStrategies } from "../hooks/use-auth";
|
||||
import { AuthForm } from "./AuthForm";
|
||||
|
||||
export type AuthScreenProps = {
|
||||
method?: "POST" | "GET";
|
||||
action?: "login" | "register";
|
||||
logo?: ReactNode;
|
||||
intro?: ReactNode;
|
||||
};
|
||||
|
||||
export function AuthScreen({ method = "POST", action = "login", logo, intro }: AuthScreenProps) {
|
||||
const { strategies, basepath, loading } = useAuthStrategies();
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col select-none h-dvh w-dvw justify-center items-center bknd-admin">
|
||||
{!loading && (
|
||||
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
|
||||
{logo ? logo : null}
|
||||
{typeof intro !== "undefined" ? (
|
||||
intro
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<h1 className="text-xl font-bold">Sign in to your admin panel</h1>
|
||||
<p className="text-primary/50">Enter your credentials below to get access.</p>
|
||||
</div>
|
||||
)}
|
||||
<AuthForm auth={{ basepath, strategies }} method={method} action={action} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
app/src/ui/elements/auth/SocialLink.tsx
Normal file
33
app/src/ui/elements/auth/SocialLink.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import type { IconType } from "ui/components/buttons/IconButton";
|
||||
|
||||
export type SocialLinkProps = {
|
||||
label?: string;
|
||||
provider: string;
|
||||
icon?: IconType;
|
||||
action: "login" | "register";
|
||||
method?: "GET" | "POST";
|
||||
basepath?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function SocialLink({
|
||||
label,
|
||||
provider,
|
||||
icon,
|
||||
action,
|
||||
method = "POST",
|
||||
basepath = "/api/auth",
|
||||
children
|
||||
}: SocialLinkProps) {
|
||||
return (
|
||||
<form method={method} action={[basepath, name, action].join("/")} className="w-full">
|
||||
<Button type="submit" size="large" variant="outline" className="justify-center w-full">
|
||||
Continue with {label ?? ucFirstAllSnakeToPascalWithSpaces(provider)}
|
||||
</Button>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
9
app/src/ui/elements/auth/index.ts
Normal file
9
app/src/ui/elements/auth/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { AuthForm } from "./AuthForm";
|
||||
import { AuthScreen } from "./AuthScreen";
|
||||
import { SocialLink } from "./SocialLink";
|
||||
|
||||
export const Auth = {
|
||||
Screen: AuthScreen,
|
||||
Form: AuthForm,
|
||||
SocialLink: SocialLink
|
||||
};
|
||||
23
app/src/ui/elements/hooks/use-auth.ts
Normal file
23
app/src/ui/elements/hooks/use-auth.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { AppAuthSchema } from "auth/auth-schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useApi } from "ui/client";
|
||||
|
||||
type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">;
|
||||
export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthStrategyData> & {
|
||||
loading: boolean;
|
||||
} => {
|
||||
const [data, setData] = useState<AuthStrategyData>();
|
||||
const api = useApi(options?.baseUrl);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await api.auth.strategies();
|
||||
//console.log("res", res);
|
||||
if (res.res.ok) {
|
||||
setData(res.body);
|
||||
}
|
||||
})();
|
||||
}, [options?.baseUrl]);
|
||||
|
||||
return { strategies: data?.strategies, basepath: data?.basepath, loading: !data };
|
||||
};
|
||||
@@ -1,2 +1,2 @@
|
||||
export { Auth } from "ui/modules/auth/index";
|
||||
export { Auth } from "./auth";
|
||||
export * from "./media";
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { PreviewWrapperMemoized } from "ui/modules/media/components/dropzone/Dropzone";
|
||||
import { DropzoneContainer } from "ui/modules/media/components/dropzone/DropzoneContainer";
|
||||
|
||||
export const Media = {
|
||||
Dropzone: DropzoneContainer,
|
||||
Preview: PreviewWrapperMemoized
|
||||
};
|
||||
|
||||
export type {
|
||||
PreviewComponentProps,
|
||||
FileState,
|
||||
DropzoneProps,
|
||||
DropzoneRenderProps
|
||||
} from "ui/modules/media/components/dropzone/Dropzone";
|
||||
export type { DropzoneContainerProps } from "ui/modules/media/components/dropzone/DropzoneContainer";
|
||||
494
app/src/ui/elements/media/Dropzone.tsx
Normal file
494
app/src/ui/elements/media/Dropzone.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import {
|
||||
type ComponentPropsWithRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
type RefObject,
|
||||
memo,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { TbDots } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
import { type FileWithPath, useDropzone } from "./use-dropzone";
|
||||
|
||||
export type FileState = {
|
||||
body: FileWithPath | string;
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
state: "pending" | "uploading" | "uploaded" | "failed" | "initial" | "deleting";
|
||||
progress: number;
|
||||
};
|
||||
|
||||
export type DropzoneRenderProps = {
|
||||
wrapperRef: RefObject<HTMLDivElement>;
|
||||
inputProps: ComponentPropsWithRef<"input">;
|
||||
state: {
|
||||
files: FileState[];
|
||||
isOver: boolean;
|
||||
isOverAccepted: boolean;
|
||||
showPlaceholder: boolean;
|
||||
};
|
||||
actions: {
|
||||
uploadFile: (file: FileState) => Promise<void>;
|
||||
deleteFile: (file: FileState) => Promise<void>;
|
||||
openFileInput: () => void;
|
||||
};
|
||||
dropzoneProps: Pick<DropzoneProps, "maxItems" | "placeholder" | "autoUpload">;
|
||||
};
|
||||
|
||||
export type DropzoneProps = {
|
||||
getUploadInfo: (file: FileWithPath) => { url: string; headers?: Headers; method?: string };
|
||||
handleDelete: (file: FileState) => Promise<boolean>;
|
||||
initialItems?: FileState[];
|
||||
maxItems?: number;
|
||||
overwrite?: boolean;
|
||||
autoUpload?: boolean;
|
||||
onRejected?: (files: FileWithPath[]) => void;
|
||||
onDeleted?: (file: FileState) => void;
|
||||
onUploaded?: (file: FileState) => void;
|
||||
placeholder?: {
|
||||
show?: boolean;
|
||||
text?: string;
|
||||
};
|
||||
children?: (props: DropzoneRenderProps) => JSX.Element;
|
||||
};
|
||||
|
||||
export function Dropzone({
|
||||
getUploadInfo,
|
||||
handleDelete,
|
||||
initialItems = [],
|
||||
maxItems,
|
||||
overwrite,
|
||||
autoUpload,
|
||||
placeholder,
|
||||
onRejected,
|
||||
onDeleted,
|
||||
onUploaded,
|
||||
children
|
||||
}: DropzoneProps) {
|
||||
const [files, setFiles] = useState<FileState[]>(initialItems);
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isOverAccepted, setIsOverAccepted] = useState(false);
|
||||
|
||||
function isMaxReached(added: number): boolean {
|
||||
if (!maxItems) {
|
||||
console.log("maxItems is undefined, never reached");
|
||||
return false;
|
||||
}
|
||||
|
||||
const current = files.length;
|
||||
const remaining = maxItems - current;
|
||||
console.log("isMaxReached", { added, current, remaining, maxItems, overwrite });
|
||||
|
||||
// if overwrite is set, but added is bigger than max items
|
||||
if (overwrite) {
|
||||
console.log("added > maxItems, stop?", added > maxItems);
|
||||
return added > maxItems;
|
||||
}
|
||||
console.log("remaining > added, stop?", remaining > added);
|
||||
// or remaining doesn't suffice, stop
|
||||
return added > remaining;
|
||||
}
|
||||
|
||||
const { isOver, handleFileInputChange, ref } = useDropzone({
|
||||
onDropped: (newFiles: FileWithPath[]) => {
|
||||
let to_drop = 0;
|
||||
const added = newFiles.length;
|
||||
|
||||
if (maxItems) {
|
||||
if (isMaxReached(added)) {
|
||||
if (onRejected) {
|
||||
onRejected(newFiles);
|
||||
} else {
|
||||
console.warn("maxItems reached");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
to_drop = added;
|
||||
}
|
||||
|
||||
console.log("files", newFiles, { to_drop });
|
||||
setFiles((prev) => {
|
||||
// drop amount calculated
|
||||
const _prev = prev.slice(to_drop);
|
||||
|
||||
// prep new files
|
||||
const currentPaths = _prev.map((f) => f.path);
|
||||
const filteredFiles: FileState[] = newFiles
|
||||
.filter((f) => f.path && !currentPaths.includes(f.path))
|
||||
.map((f) => ({
|
||||
body: f,
|
||||
path: f.path!,
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type,
|
||||
state: "pending",
|
||||
progress: 0
|
||||
}));
|
||||
|
||||
return [..._prev, ...filteredFiles];
|
||||
});
|
||||
|
||||
if (autoUpload) {
|
||||
setUploading(true);
|
||||
}
|
||||
},
|
||||
onOver: (items) => {
|
||||
const max_reached = isMaxReached(items.length);
|
||||
setIsOverAccepted(!max_reached);
|
||||
},
|
||||
onLeave: () => {
|
||||
setIsOverAccepted(false);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
console.log("files updated");
|
||||
}, [files]);
|
||||
|
||||
useEffect(() => {
|
||||
if (uploading) {
|
||||
(async () => {
|
||||
const pendingFiles = files.filter((f) => f.state === "pending");
|
||||
if (pendingFiles.length === 0) {
|
||||
setUploading(false);
|
||||
return;
|
||||
} else {
|
||||
for (const file of pendingFiles) {
|
||||
await uploadFileProgress(file);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [uploading]);
|
||||
|
||||
function setFileState(path: string, state: FileState["state"], progress?: number) {
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => {
|
||||
//console.log("compare", f.path, path, f.path === path);
|
||||
if (f.path === path) {
|
||||
return {
|
||||
...f,
|
||||
state,
|
||||
progress: progress ?? f.progress
|
||||
};
|
||||
}
|
||||
return f;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function replaceFileState(prevPath: string, newState: Partial<FileState>) {
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => {
|
||||
if (f.path === prevPath) {
|
||||
return {
|
||||
...f,
|
||||
...newState
|
||||
};
|
||||
}
|
||||
return f;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function removeFileFromState(path: string) {
|
||||
setFiles((prev) => prev.filter((f) => f.path !== path));
|
||||
}
|
||||
|
||||
function uploadFileProgress(file: FileState) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!file.body) {
|
||||
console.error("File has no body");
|
||||
reject();
|
||||
return;
|
||||
} else if (file.state !== "pending") {
|
||||
console.error("File is not pending");
|
||||
reject();
|
||||
return;
|
||||
} else if (file.body instanceof File === false) {
|
||||
console.error("File body is not a File instance");
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
const { url, headers, method = "POST" } = getUploadInfo(file.body);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file.body);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
const urlWithParams = new URL(url);
|
||||
if (overwrite) {
|
||||
urlWithParams.searchParams.append("overwrite", "1");
|
||||
}
|
||||
console.log("url", urlWithParams.toString());
|
||||
//return;
|
||||
|
||||
xhr.open(method, urlWithParams.toString(), true);
|
||||
|
||||
if (headers) {
|
||||
headers.forEach((value, key) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle progress events
|
||||
xhr.upload.addEventListener("progress", (event) => {
|
||||
console.log("progress", event.loaded, event.total);
|
||||
if (event.lengthComputable) {
|
||||
setFileState(file.path, "uploading", event.loaded / event.total);
|
||||
const percentComplete = (event.loaded / event.total) * 100;
|
||||
console.log(`Progress: ${percentComplete.toFixed(2)}%`);
|
||||
} else {
|
||||
console.log(
|
||||
"Unable to compute progress information since the total size is unknown"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.onload = () => {
|
||||
console.log("onload", file.path, xhr.status);
|
||||
if (xhr.status === 200) {
|
||||
//setFileState(file.path, "uploaded", 1);
|
||||
console.log("Upload complete");
|
||||
onUploaded?.(file);
|
||||
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
|
||||
console.log("Response:", file, response);
|
||||
console.log("New state", response.state);
|
||||
replaceFileState(file.path, {
|
||||
...response.state,
|
||||
progress: 1,
|
||||
state: "uploaded"
|
||||
});
|
||||
} catch (e) {
|
||||
setFileState(file.path, "uploaded", 1);
|
||||
console.error("Error parsing response", e);
|
||||
}
|
||||
resolve();
|
||||
} else {
|
||||
setFileState(file.path, "failed", 1);
|
||||
console.error("Upload failed with status: ", xhr.status);
|
||||
reject();
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
console.error("Error during the upload process.");
|
||||
};
|
||||
xhr.onloadstart = () => {
|
||||
setFileState(file.path, "uploading", 0);
|
||||
console.log("loadstart");
|
||||
};
|
||||
|
||||
xhr.setRequestHeader("Accept", "application/json");
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteFile(file: FileState) {
|
||||
console.log("deleteFile", file);
|
||||
switch (file.state) {
|
||||
case "uploaded":
|
||||
case "initial":
|
||||
if (window.confirm("Are you sure you want to delete this file?")) {
|
||||
console.log('setting state to "deleting"', file);
|
||||
setFileState(file.path, "deleting");
|
||||
await handleDelete(file);
|
||||
removeFileFromState(file.path);
|
||||
onDeleted?.(file);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const openFileInput = () => inputRef.current?.click();
|
||||
const showPlaceholder = Boolean(
|
||||
placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)
|
||||
);
|
||||
|
||||
const renderProps: DropzoneRenderProps = {
|
||||
wrapperRef: ref,
|
||||
inputProps: {
|
||||
ref: inputRef,
|
||||
type: "file",
|
||||
multiple: !maxItems || maxItems > 1,
|
||||
onChange: handleFileInputChange
|
||||
},
|
||||
state: {
|
||||
files,
|
||||
isOver,
|
||||
isOverAccepted,
|
||||
showPlaceholder
|
||||
},
|
||||
actions: {
|
||||
uploadFile: uploadFileProgress,
|
||||
deleteFile,
|
||||
openFileInput
|
||||
},
|
||||
dropzoneProps: {
|
||||
maxItems,
|
||||
placeholder,
|
||||
autoUpload
|
||||
}
|
||||
};
|
||||
|
||||
return children ? children(renderProps) : <DropzoneInner {...renderProps} />;
|
||||
}
|
||||
|
||||
const DropzoneInner = ({
|
||||
wrapperRef,
|
||||
inputProps,
|
||||
state: { files, isOver, isOverAccepted, showPlaceholder },
|
||||
actions: { uploadFile, deleteFile, openFileInput },
|
||||
dropzoneProps: { placeholder }
|
||||
}: DropzoneRenderProps) => {
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={twMerge(
|
||||
"dropzone w-full h-full align-start flex flex-col select-none",
|
||||
isOver && isOverAccepted && "bg-green-200/10",
|
||||
isOver && !isOverAccepted && "bg-red-200/40 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className="hidden">
|
||||
<input {...inputProps} />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-row flex-wrap gap-2 md:gap-3">
|
||||
{files.map((file) => (
|
||||
<Preview
|
||||
key={file.path}
|
||||
file={file}
|
||||
handleUpload={uploadFile}
|
||||
handleDelete={deleteFile}
|
||||
/>
|
||||
))}
|
||||
{showPlaceholder && (
|
||||
<UploadPlaceholder onClick={openFileInput} text={placeholder?.text} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
|
||||
return (
|
||||
<div
|
||||
className="w-[49%] aspect-[1/0.9] md:w-60 flex flex-col border-2 border-dashed border-muted relative justify-center items-center text-primary/30 hover:border-primary/30 hover:text-primary/50 hover:cursor-pointer hover:bg-muted/20 transition-colors duration-200"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="">{text}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type PreviewComponentProps = {
|
||||
file: FileState;
|
||||
fallback?: (props: { file: FileState }) => JSX.Element;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
onTouchStart?: () => void;
|
||||
};
|
||||
|
||||
const Wrapper = ({ file, fallback, ...props }: PreviewComponentProps) => {
|
||||
if (file.type.startsWith("image/")) {
|
||||
return <ImagePreview {...props} file={file} />;
|
||||
}
|
||||
|
||||
if (file.type.startsWith("video/")) {
|
||||
return <VideoPreview {...props} file={file} />;
|
||||
}
|
||||
|
||||
return fallback ? fallback({ file }) : null;
|
||||
};
|
||||
export const PreviewWrapperMemoized = memo(
|
||||
Wrapper,
|
||||
(prev, next) => prev.file.path === next.file.path
|
||||
);
|
||||
|
||||
type PreviewProps = {
|
||||
file: FileState;
|
||||
handleUpload: (file: FileState) => Promise<void>;
|
||||
handleDelete: (file: FileState) => Promise<void>;
|
||||
};
|
||||
const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) => {
|
||||
const dropdownItems = [
|
||||
["initial", "uploaded"].includes(file.state) && {
|
||||
label: "Delete",
|
||||
onClick: () => handleDelete(file)
|
||||
},
|
||||
["initial", "pending"].includes(file.state) && {
|
||||
label: "Upload",
|
||||
onClick: () => handleUpload(file)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-[49%] md:w-60 flex flex-col border border-muted relative",
|
||||
file.state === "deleting" && "opacity-70"
|
||||
)}
|
||||
>
|
||||
<div className="absolute top-2 right-2">
|
||||
<Dropdown items={dropdownItems} position="bottom-end">
|
||||
<IconButton Icon={TbDots} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
{file.state === "uploading" && (
|
||||
<div className="absolute w-full top-0 left-0 right-0 h-1">
|
||||
<div
|
||||
className="bg-blue-600 h-1 transition-all duration-75"
|
||||
style={{ width: (file.progress * 100).toFixed(0) + "%" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex bg-primary/5 aspect-[1/0.8] overflow-hidden items-center justify-center">
|
||||
<PreviewWrapperMemoized
|
||||
file={file}
|
||||
fallback={FallbackPreview}
|
||||
className="max-w-full max-h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col px-1.5 py-1">
|
||||
<p className="truncate">{file.name}</p>
|
||||
<div className="flex flex-row justify-between text-sm font-mono opacity-50 text-nowrap gap-2">
|
||||
<span className="truncate">{file.type}</span>
|
||||
<span>{(file.size / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ImagePreview = ({
|
||||
file,
|
||||
...props
|
||||
}: { file: FileState } & ComponentPropsWithoutRef<"img">) => {
|
||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||
return <img {...props} src={objectUrl} />;
|
||||
};
|
||||
|
||||
const VideoPreview = ({
|
||||
file,
|
||||
...props
|
||||
}: { file: FileState } & ComponentPropsWithoutRef<"video">) => {
|
||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||
return <video {...props} src={objectUrl} />;
|
||||
};
|
||||
|
||||
const FallbackPreview = ({ file }: { file: FileState }) => {
|
||||
return <div className="text-xs text-primary/50 text-center">{file.type}</div>;
|
||||
};
|
||||
93
app/src/ui/elements/media/DropzoneContainer.tsx
Normal file
93
app/src/ui/elements/media/DropzoneContainer.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { RepoQuery } from "data";
|
||||
import type { MediaFieldSchema } from "media/AppMedia";
|
||||
import type { TAppMediaConfig } from "media/media-schema";
|
||||
import { useId } from "react";
|
||||
import { useApi, useBaseUrl, useEntityQuery, useInvalidate } from "ui/client";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { Dropzone, type DropzoneProps, type DropzoneRenderProps, type FileState } from "./Dropzone";
|
||||
import { mediaItemsToFileStates } from "./helper";
|
||||
|
||||
export type DropzoneContainerProps = {
|
||||
children?: (props: DropzoneRenderProps) => JSX.Element;
|
||||
initialItems?: MediaFieldSchema[];
|
||||
entity?: {
|
||||
name: string;
|
||||
id: number;
|
||||
field: string;
|
||||
};
|
||||
query?: Partial<RepoQuery>;
|
||||
} & Partial<Pick<TAppMediaConfig, "basepath" | "entity_name" | "storage">> &
|
||||
Partial<DropzoneProps>;
|
||||
|
||||
export function DropzoneContainer({
|
||||
initialItems,
|
||||
basepath = "/api/media",
|
||||
storage = {},
|
||||
entity_name = "media",
|
||||
entity,
|
||||
query,
|
||||
...props
|
||||
}: DropzoneContainerProps) {
|
||||
const id = useId();
|
||||
const baseUrl = useBaseUrl();
|
||||
const api = useApi();
|
||||
const invalidate = useInvalidate();
|
||||
const limit = query?.limit ? query?.limit : props.maxItems ? props.maxItems : 50;
|
||||
|
||||
const $q = useEntityQuery(
|
||||
entity_name as "media",
|
||||
undefined,
|
||||
{
|
||||
...query,
|
||||
limit,
|
||||
where: entity
|
||||
? {
|
||||
reference: `${entity.name}.${entity.field}`,
|
||||
entity_id: entity.id,
|
||||
...query?.where
|
||||
}
|
||||
: query?.where
|
||||
},
|
||||
{ enabled: !initialItems }
|
||||
);
|
||||
|
||||
const getUploadInfo = useEvent((file) => {
|
||||
const url = entity
|
||||
? api.media.getEntityUploadUrl(entity.name, entity.id, entity.field)
|
||||
: api.media.getFileUploadUrl(file);
|
||||
|
||||
return {
|
||||
url,
|
||||
headers: api.media.getUploadHeaders(),
|
||||
method: "POST"
|
||||
};
|
||||
});
|
||||
|
||||
const refresh = useEvent(async () => {
|
||||
if (entity) {
|
||||
invalidate((api) => api.data.readOne(entity.name, entity.id));
|
||||
}
|
||||
await $q.mutate();
|
||||
});
|
||||
|
||||
const handleDelete = useEvent(async (file: FileState) => {
|
||||
return api.media.deleteFile(file.path);
|
||||
});
|
||||
|
||||
const actualItems = initialItems ?? (($q.data || []) as MediaFieldSchema[]);
|
||||
const _initialItems = mediaItemsToFileStates(actualItems, { baseUrl });
|
||||
|
||||
const key = id + JSON.stringify(_initialItems);
|
||||
return (
|
||||
<Dropzone
|
||||
key={id + key}
|
||||
getUploadInfo={getUploadInfo}
|
||||
handleDelete={handleDelete}
|
||||
onUploaded={refresh}
|
||||
onDeleted={refresh}
|
||||
autoUpload
|
||||
initialItems={_initialItems}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
260
app/src/ui/elements/media/file-selector.ts
Normal file
260
app/src/ui/elements/media/file-selector.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* From https://github.com/react-dropzone/file-selector
|
||||
* slightly adjusted
|
||||
* MIT License (2020 Roland Groza)
|
||||
*/
|
||||
|
||||
import { guess } from "media/storage/mime-types-tiny";
|
||||
|
||||
const FILES_TO_IGNORE = [
|
||||
// Thumbnail cache files for macOS and Windows
|
||||
".DS_Store", // macOs
|
||||
"Thumbs.db" // Windows
|
||||
];
|
||||
|
||||
export function toFileWithPath(file: FileWithPath, path?: string): FileWithPath {
|
||||
const f = withMimeType(file);
|
||||
if (typeof f.path !== "string") {
|
||||
// on electron, path is already set to the absolute path
|
||||
const { webkitRelativePath } = file;
|
||||
Object.defineProperty(f, "path", {
|
||||
value:
|
||||
typeof path === "string"
|
||||
? path
|
||||
: // If <input webkitdirectory> is set,
|
||||
// the File will have a {webkitRelativePath} property
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/webkitdirectory
|
||||
typeof webkitRelativePath === "string" && webkitRelativePath.length > 0
|
||||
? webkitRelativePath
|
||||
: file.name,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
enumerable: true
|
||||
});
|
||||
}
|
||||
|
||||
return f;
|
||||
}
|
||||
|
||||
export interface FileWithPath extends File {
|
||||
readonly path?: string;
|
||||
}
|
||||
|
||||
function withMimeType(file: FileWithPath) {
|
||||
const { name } = file;
|
||||
const hasExtension = name && name.lastIndexOf(".") !== -1;
|
||||
|
||||
console.log("withMimeType", name, hasExtension);
|
||||
|
||||
if (hasExtension && !file.type) {
|
||||
const type = guess(name);
|
||||
console.log("guessed", type);
|
||||
|
||||
if (type) {
|
||||
Object.defineProperty(file, "type", {
|
||||
value: type,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
enumerable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
export interface FileWithPath extends File {
|
||||
readonly path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a DragEvent's DataTrasfer object to a list of File objects
|
||||
* NOTE: If some of the items are folders,
|
||||
* everything will be flattened and placed in the same list but the paths will be kept as a {path} property.
|
||||
*
|
||||
* EXPERIMENTAL: A list of https://developer.mozilla.org/en-US/docs/Web/API/FileSystemHandle objects can also be passed as an arg
|
||||
* and a list of File objects will be returned.
|
||||
*
|
||||
* @param evt
|
||||
*/
|
||||
export async function fromEvent(evt: Event | any): Promise<(FileWithPath | DataTransferItem)[]> {
|
||||
if (isObject<DragEvent>(evt) && isDataTransfer(evt.dataTransfer)) {
|
||||
return getDataTransferFiles(evt.dataTransfer, evt.type);
|
||||
// biome-ignore lint/style/noUselessElse: not useless
|
||||
} else if (isChangeEvt(evt)) {
|
||||
return getInputFiles(evt);
|
||||
// biome-ignore lint/style/noUselessElse: not useless
|
||||
} else if (
|
||||
Array.isArray(evt) &&
|
||||
evt.every((item) => "getFile" in item && typeof item.getFile === "function")
|
||||
) {
|
||||
return getFsHandleFiles(evt);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function isDataTransfer(value: any): value is DataTransfer {
|
||||
return isObject(value);
|
||||
}
|
||||
|
||||
function isChangeEvt(value: any): value is Event {
|
||||
return isObject<Event>(value) && isObject(value.target);
|
||||
}
|
||||
|
||||
function isObject<T>(v: any): v is T {
|
||||
return typeof v === "object" && v !== null;
|
||||
}
|
||||
|
||||
function getInputFiles(evt: Event) {
|
||||
return fromList<FileWithPath>((evt.target as HTMLInputElement).files).map((file) =>
|
||||
toFileWithPath(file)
|
||||
);
|
||||
}
|
||||
|
||||
// Ee expect each handle to be https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileHandle
|
||||
async function getFsHandleFiles(handles: any[]) {
|
||||
const files = await Promise.all(handles.map((h) => h.getFile()));
|
||||
return files.map((file) => toFileWithPath(file));
|
||||
}
|
||||
|
||||
async function getDataTransferFiles(dt: DataTransfer, type: string) {
|
||||
// IE11 does not support dataTransfer.items
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/items#Browser_compatibility
|
||||
if (dt.items) {
|
||||
const items = fromList<DataTransferItem>(dt.items).filter((item) => item.kind === "file");
|
||||
// According to https://html.spec.whatwg.org/multipage/dnd.html#dndevents,
|
||||
// only 'dragstart' and 'drop' has access to the data (source node)
|
||||
if (type !== "drop") {
|
||||
return items;
|
||||
}
|
||||
const files = await Promise.all(items.map(toFilePromises));
|
||||
return noIgnoredFiles(flatten<FileWithPath>(files));
|
||||
}
|
||||
|
||||
return noIgnoredFiles(fromList<FileWithPath>(dt.files).map((file) => toFileWithPath(file)));
|
||||
}
|
||||
|
||||
function noIgnoredFiles(files: FileWithPath[]) {
|
||||
return files.filter((file) => FILES_TO_IGNORE.indexOf(file.name) === -1);
|
||||
}
|
||||
|
||||
// IE11 does not support Array.from()
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from#Browser_compatibility
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/FileList
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItemList
|
||||
function fromList<T>(items: DataTransferItemList | FileList | null): T[] {
|
||||
if (items === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files: any[] = [];
|
||||
|
||||
// tslint:disable: prefer-for-of
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const file = items[i];
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem
|
||||
function toFilePromises(item: DataTransferItem) {
|
||||
if (typeof item.webkitGetAsEntry !== "function") {
|
||||
return fromDataTransferItem(item);
|
||||
}
|
||||
|
||||
const entry = item.webkitGetAsEntry();
|
||||
|
||||
// Safari supports dropping an image node from a different window and can be retrieved using
|
||||
// the DataTransferItem.getAsFile() API
|
||||
// NOTE: FileSystemEntry.file() throws if trying to get the file
|
||||
if (entry?.isDirectory) {
|
||||
return fromDirEntry(entry) as any;
|
||||
}
|
||||
|
||||
return fromDataTransferItem(item);
|
||||
}
|
||||
|
||||
function flatten<T>(items: any[]): T[] {
|
||||
return items.reduce(
|
||||
(acc, files) => [
|
||||
// biome-ignore lint/performance/noAccumulatingSpread: <explanation>
|
||||
...acc,
|
||||
...(Array.isArray(files) ? flatten(files) : [files])
|
||||
],
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
function fromDataTransferItem(item: DataTransferItem) {
|
||||
const file = item.getAsFile();
|
||||
if (!file) {
|
||||
return Promise.reject(`${item} is not a File`);
|
||||
}
|
||||
const fwp = toFileWithPath(file);
|
||||
return Promise.resolve(fwp);
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemEntry
|
||||
async function fromEntry(entry: any) {
|
||||
return entry.isDirectory ? fromDirEntry(entry) : fromFileEntry(entry);
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryEntry
|
||||
function fromDirEntry(entry: any) {
|
||||
const reader = entry.createReader();
|
||||
|
||||
return new Promise<FileArray[]>((resolve, reject) => {
|
||||
const entries: Promise<FileValue[]>[] = [];
|
||||
|
||||
function readEntries() {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryEntry/createReader
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryReader/readEntries
|
||||
reader.readEntries(
|
||||
async (batch: any[]) => {
|
||||
if (!batch.length) {
|
||||
// Done reading directory
|
||||
try {
|
||||
const files = await Promise.all(entries);
|
||||
resolve(files);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
} else {
|
||||
const items = Promise.all(batch.map(fromEntry));
|
||||
entries.push(items);
|
||||
|
||||
// Continue reading
|
||||
readEntries();
|
||||
}
|
||||
},
|
||||
(err: any) => {
|
||||
reject(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
readEntries();
|
||||
});
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileEntry
|
||||
async function fromFileEntry(entry: any) {
|
||||
return new Promise<FileWithPath>((resolve, reject) => {
|
||||
entry.file(
|
||||
(file: FileWithPath) => {
|
||||
const fwp = toFileWithPath(file, entry.fullPath);
|
||||
resolve(fwp);
|
||||
},
|
||||
(err: any) => {
|
||||
reject(err);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Infinite type recursion
|
||||
// https://github.com/Microsoft/TypeScript/issues/3496#issuecomment-128553540
|
||||
interface FileArray extends Array<FileValue> {}
|
||||
type FileValue = FileWithPath | FileArray[];
|
||||
31
app/src/ui/elements/media/helper.ts
Normal file
31
app/src/ui/elements/media/helper.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { MediaFieldSchema } from "media/AppMedia";
|
||||
import type { FileState } from "./Dropzone";
|
||||
|
||||
export function mediaItemToFileState(
|
||||
item: MediaFieldSchema,
|
||||
options: {
|
||||
overrides?: Partial<FileState>;
|
||||
baseUrl?: string;
|
||||
} = { overrides: {}, baseUrl: "" }
|
||||
): FileState {
|
||||
return {
|
||||
body: `${options.baseUrl}/api/media/file/${item.path}`,
|
||||
path: item.path,
|
||||
name: item.path,
|
||||
size: item.size ?? 0,
|
||||
type: item.mime_type ?? "",
|
||||
state: "uploaded",
|
||||
progress: 0,
|
||||
...options.overrides
|
||||
};
|
||||
}
|
||||
|
||||
export function mediaItemsToFileStates(
|
||||
items: MediaFieldSchema[],
|
||||
options: {
|
||||
overrides?: Partial<FileState>;
|
||||
baseUrl?: string;
|
||||
} = { overrides: {}, baseUrl: "" }
|
||||
): FileState[] {
|
||||
return items.map((item) => mediaItemToFileState(item, options));
|
||||
}
|
||||
15
app/src/ui/elements/media/index.ts
Normal file
15
app/src/ui/elements/media/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PreviewWrapperMemoized } from "./Dropzone";
|
||||
import { DropzoneContainer } from "./DropzoneContainer";
|
||||
|
||||
export const Media = {
|
||||
Dropzone: DropzoneContainer,
|
||||
Preview: PreviewWrapperMemoized
|
||||
};
|
||||
|
||||
export type {
|
||||
PreviewComponentProps,
|
||||
FileState,
|
||||
DropzoneProps,
|
||||
DropzoneRenderProps
|
||||
} from "./Dropzone";
|
||||
export type { DropzoneContainerProps } from "./DropzoneContainer";
|
||||
82
app/src/ui/elements/media/use-dropzone.ts
Normal file
82
app/src/ui/elements/media/use-dropzone.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { type ChangeEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { type FileWithPath, fromEvent } from "./file-selector";
|
||||
|
||||
type DropzoneProps = {
|
||||
onDropped: (files: FileWithPath[]) => void;
|
||||
onOver?: (items: DataTransferItem[]) => void;
|
||||
onLeave?: () => void;
|
||||
};
|
||||
|
||||
const events = {
|
||||
enter: ["dragenter", "dragover", "dragstart"],
|
||||
leave: ["dragleave", "drop"]
|
||||
};
|
||||
const allEvents = [...events.enter, ...events.leave];
|
||||
|
||||
export function useDropzone({ onDropped, onOver, onLeave }: DropzoneProps) {
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const onOverCalled = useRef(false);
|
||||
|
||||
// Prevent default behavior (Prevent file from being opened)
|
||||
const preventDefaults = useCallback((e: Event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const toggleHighlight = useCallback(async (e: Event) => {
|
||||
const _isOver = events.enter.includes(e.type);
|
||||
if (onOver && _isOver !== isOver && !onOverCalled.current) {
|
||||
onOver((await fromEvent(e)) as DataTransferItem[]);
|
||||
onOverCalled.current = true;
|
||||
}
|
||||
|
||||
setIsOver(_isOver);
|
||||
|
||||
if (_isOver === false && onOverCalled.current) {
|
||||
onOverCalled.current = false;
|
||||
onLeave?.();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: DragEvent) => {
|
||||
const files = await fromEvent(e);
|
||||
onDropped?.(files as any);
|
||||
onOverCalled.current = false;
|
||||
},
|
||||
[onDropped]
|
||||
);
|
||||
|
||||
const handleFileInputChange = useCallback(
|
||||
async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = await fromEvent(e);
|
||||
onDropped?.(files as any);
|
||||
},
|
||||
[onDropped]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el: HTMLDivElement = ref.current!;
|
||||
|
||||
allEvents.forEach((eventName) => {
|
||||
el.addEventListener(eventName, preventDefaults);
|
||||
el.addEventListener(eventName, toggleHighlight);
|
||||
});
|
||||
|
||||
// Handle dropped files
|
||||
el.addEventListener("drop", handleDrop);
|
||||
|
||||
return () => {
|
||||
allEvents.forEach((eventName) => {
|
||||
el.removeEventListener(eventName, preventDefaults);
|
||||
el.removeEventListener(eventName, toggleHighlight);
|
||||
});
|
||||
el.removeEventListener("drop", handleDrop);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ref, isOver, fromEvent, onDropped, handleFileInputChange };
|
||||
}
|
||||
|
||||
export type { FileWithPath };
|
||||
3
app/src/ui/elements/mocks/tailwind-merge.ts
Normal file
3
app/src/ui/elements/mocks/tailwind-merge.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function twMerge(...classes: string[]) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user