2025-04-04 06:04:31 +03:00

980 lines
27 KiB
TypeScript

import {
Avatar,
Box,
Breadcrumbs,
Button,
ButtonBase,
Card,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
FormControlLabel,
IconButton,
Radio,
RadioGroup,
TextField,
Typography,
} from "@mui/material";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { IndexCategory, useIndexStore } from "../../state/indexes";
import CloseIcon from "@mui/icons-material/Close";
import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos";
import { hashWordWithoutPublicSalt } from "../../utils/encryption";
import { usePublish } from "../../hooks/usePublish";
import {
dismissToast,
showError,
showLoading,
showSuccess,
} from "../../utils/toast";
import { objectToBase64 } from "../../utils/base64";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { OptionsManager } from "../OptionsManager";
import ShortUniqueId from "short-unique-id";
import { Spacer } from "../../common/Spacer";
import { useModal } from "../useModal";
import { createAvatarLink } from "../../utils/qortal";
import { extractComponents } from "../../utils/text";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
const uid = new ShortUniqueId({ length: 10, dictionary: "alphanum" });
interface PropsMode {
link: string;
name: string;
mode: number;
setMode: (mode: number) => void;
username: string;
}
interface PropsIndexManager {
username: string | null;
}
const cleanString = (str: string) => str.replace(/\s{2,}/g, ' ').trim().toLocaleLowerCase();
export const IndexManager = ({ username }: PropsIndexManager) => {
const open = useIndexStore((state) => state.open);
const setOpen = useIndexStore((state) => state.setOpen);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [hasMetadata, setHasMetadata] = useState(false);
const [mode, setMode] = useState(1);
const publish = usePublish();
const handleClose = () => {
setOpen(null);
setMode(1);
setTitle("");
setDescription("");
setHasMetadata(false);
};
const getMetadata = useCallback(async (name: string, link: string) => {
try {
const identifierWithoutHash = name + link;
const identifier = await hashWordWithoutPublicSalt(
identifierWithoutHash,
20
);
const rawData = await publish.fetchPublish(
{
name: name,
service: "METADATA",
identifier,
},
"JSON"
);
if (
rawData?.resource &&
rawData?.resource?.data?.title &&
rawData?.resource?.data?.description
) {
setHasMetadata(true);
setDescription(rawData?.resource?.data?.description);
setTitle(rawData?.resource?.data?.title);
}
} catch (error) {}
}, []);
useEffect(() => {
if (open?.name && open?.link) {
setTimeout(() => {
getMetadata(open?.name, open?.link);
}, 100);
}
}, [open?.link, open?.name]);
if (!open || !username) return null;
return (
<Dialog
open={!!open}
fullWidth={true}
maxWidth={"md"}
sx={{
zIndex: 999990,
}}
>
<DialogTitle>Index manager</DialogTitle>
<IconButton
aria-label="close"
onClick={handleClose}
sx={(theme) => ({
position: "absolute",
right: 8,
top: 8,
})}
>
<CloseIcon />
</IconButton>
{mode === 1 && (
<EntryMode
link={open?.link}
name={open?.name}
mode={mode}
setMode={setMode}
username={username}
hasMetadata={hasMetadata}
/>
)}
{mode === 2 && (
<CreateIndex
link={open?.link}
name={open?.name}
mode={mode}
setMode={setMode}
username={username}
category={open?.category}
rootName={open?.rootName}
/>
)}
{/* {mode === 3 && (
<YourIndices
link={open?.link}
name={open?.name}
mode={mode}
setMode={setMode}
username={username}
category={open?.category}
rootName={open?.rootName}
/>
)} */}
{mode === 4 && (
<AddMetadata
link={open?.link}
name={open?.name}
mode={mode}
setMode={setMode}
username={username}
title={title}
description={description}
setDescription={setDescription}
setTitle={setTitle}
/>
)}
</Dialog>
);
};
interface PropsEntryMode extends PropsMode {
hasMetadata: boolean;
}
const EntryMode = ({
link,
name,
mode,
setMode,
username,
hasMetadata,
}: PropsEntryMode) => {
return (
<>
<DialogContent>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "20px",
width: "100%",
alignItems: "flex-start",
}}
>
<ButtonBase
sx={{
width: "100%",
}}
onClick={() => setMode(2)}
>
<Box
sx={{
p: 2,
border: "2px solid",
borderRadius: 2,
width: "100%",
}}
>
<Typography>Create new index</Typography>
</Box>
</ButtonBase>
{/* <ButtonBase
sx={{
width: "100%",
}}
>
<Box
sx={{
p: 2,
border: "2px solid",
borderRadius: 2,
width: "100%",
}}
>
<Typography>Your indices</Typography>
</Box>
</ButtonBase> */}
<ButtonBase
sx={{
width: "100%",
visibility: username === name ? 'visible' : 'hidden'
}}
onClick={() => setMode(4)}
>
<Box
sx={{
p: 2,
border: "2px solid",
borderRadius: 2,
width: "100%",
gap: "20px",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<Typography>Add metadata</Typography>
{hasMetadata && <CheckCircleIcon color="success" />}
</Box>
</ButtonBase>
</Box>
</DialogContent>
</>
);
};
interface PropsAddMetadata extends PropsMode {
title: string;
description: string;
setTitle: (val: string) => void;
setDescription: (val: string) => void;
}
const AddMetadata = ({
link,
name,
mode,
setMode,
username,
title,
description,
setDescription,
setTitle,
}: PropsAddMetadata) => {
const publish = usePublish();
const disableButton = !title.trim() || !description.trim() || !name || !link;
const createMetadata = async () => {
const loadId = showLoading("Publishing metadata...");
try {
const identifierWithoutHash = name + link;
const identifier = await hashWordWithoutPublicSalt(
identifierWithoutHash,
20
);
const objectToPublish = {
title,
description,
};
const toBase64 = await objectToBase64(objectToPublish);
const res = await qortalRequest({
action: "PUBLISH_QDN_RESOURCE",
service: "METADATA",
identifier: identifier,
base64: toBase64,
});
if (res?.signature) {
showSuccess("Successfully published metadata");
publish.updatePublish(
{
identifier,
service: "METADATA",
name: username,
},
objectToPublish
);
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to publish metadata";
showError(message);
} finally {
dismissToast(loadId);
}
};
const res = extractComponents(link || "");
const appName = res?.name || "";
return (
<>
<DialogContent>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "20px",
width: "100%",
alignItems: "flex-start",
}}
>
<IconButton disabled={mode === 1} onClick={() => setMode(1)}>
<ArrowBackIosIcon />
</IconButton>
<Typography>Example of how it could look like:</Typography>
<Card sx={{
width: '100%',
padding: '5px'
}}>
<Box sx={{ mb: 3, display: "flex", gap: 2, width: "100%" }}>
<Avatar
alt={appName}
src={createAvatarLink(appName)}
variant="square"
sx={{ width: 24, height: 24, mt: 0.5 }}
/>
<Box
sx={{
width: "calc(100% - 50px)",
}}
>
<Box
sx={{
width: "100%",
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start'
}}
>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
>
<Typography variant="body2" color="text.secondary">
{res?.service}
</Typography>
<Typography
sx={{
textAlign: "start",
}}
variant="body2"
color="text.secondary"
>
{appName}
</Typography>
<Typography
sx={{
textAlign: "start",
}}
variant="body2"
color="text.secondary"
>
{res?.path}
</Typography>
</Breadcrumbs>
</Box>
<Spacer height="10px" />
<Box
sx={{
width: "100%",
display: 'flex',
flexDirection: 'column'
}}
>
<Typography
variant="h6"
sx={{
display: "block",
textDecoration: "none",
width: "100%",
textAlign: "start",
"&:hover": { textDecoration: "underline" },
}}
>
{title}
</Typography>
</Box>
<Typography
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
}}
variant="body2"
color="text.secondary"
>
{description}
</Typography>
</Box>
</Box>
</Card>
<Box>
<Typography>Title</Typography>
<TextField
value={title}
onChange={(e) => setTitle(e.target.value)}
size="small"
placeholder="Add a title for the link"
slotProps={{
htmlInput: { maxLength: 50 },
}}
fullWidth
helperText={
<Typography
variant="caption"
color={title.length >= 50 ? "error" : "text.secondary"}
>
{title.length}/{50} characters
</Typography>
}
/>
</Box>
<Box>
<Typography>Description</Typography>
<TextField
fullWidth
value={description}
onChange={(e) => setDescription(e.target.value)}
size="small"
placeholder="Add a description for the link"
slotProps={{
htmlInput: { maxLength: 120 },
}}
helperText={
<Typography
variant="caption"
color={description.length >= 120 ? "error" : "text.secondary"}
>
{description.length}/{120} characters
</Typography>
}
/>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={createMetadata}
disabled={disableButton}
variant="contained"
>
Publish metadata
</Button>
</DialogActions>
</>
);
};
interface PropsCreateIndex extends PropsMode {
category: IndexCategory;
rootName: string;
}
const CreateIndex = ({
link,
name,
mode,
setMode,
username,
category,
rootName,
}: PropsCreateIndex) => {
const [terms, setTerms] = useState<string[]>([]);
const publish = usePublish();
const [size, setSize] = useState(0);
const [fullSize, setFullSize] = useState(0);
const { isShow, onCancel, onOk, show } = useModal();
const [recommendedIndices, setRecommendedIndices] = useState([])
const [recommendedSelection, setRecommendedSelection] = useState("")
const objectToPublish = useMemo(() => {
if(recommendedSelection){
return [
{
n: name,
t: cleanString(recommendedSelection),
c: category,
l: link,
}
]
}
return terms?.map((term) => {
return {
n: name,
t: cleanString(term),
c: category,
l: link,
};
});
}, [name, terms, category, link, recommendedSelection]);
const objectToCalculateSize = useMemo(() => {
return [
{
n: name,
t: "",
c: category,
l: link,
},
];
}, [name, category, link]);
const shouldRecommendMax = !recommendedSelection && terms?.length === 1 && 230 - size > 50;
const recommendedSize = 230 - size;
useEffect(() => {
const getSize = async (data: any, data2: any) => {
try {
const toBase64 = await objectToBase64(data);
const size = toBase64?.length;
setSize(size);
const toBase642 = await objectToBase64(data2);
const size2 = toBase642?.length;
setFullSize(size2);
} catch (error) {}
};
getSize(objectToCalculateSize, objectToPublish);
}, [objectToCalculateSize, objectToPublish]);
const getRecommendedIndices = useCallback(async (nameParam: string, linkParam: string, rootNameParam: string)=> {
try {
const hashedRootName = await hashWordWithoutPublicSalt(rootNameParam, 20);
const hashedLink = await hashWordWithoutPublicSalt(linkParam, 20);
const identifier = `idx-${hashedRootName}-${hashedLink}-`;
const res = await fetch(`/arbitrary/indices/${nameParam}/${identifier}`)
const data = await res.json()
setRecommendedIndices(data)
} catch (error) {
}
}, [])
useEffect(()=> {
if(name && link && rootName){
getRecommendedIndices(name, link, rootName)
}
}, [name, link, rootName, getRecommendedIndices])
const disableButton = (terms.length === 0 && !recommendedSelection) || !name || !link;
const createIndex = async () => {
const loadId = showLoading("Publishing index...");
try {
const hashedRootName = await hashWordWithoutPublicSalt(rootName, 20);
const hashedLink = await hashWordWithoutPublicSalt(link, 20);
const randomUid = uid.rnd();
const identifier = `idx-${hashedRootName}-${hashedLink}-${randomUid}`;
const toBase64 = await objectToBase64(objectToPublish);
const res = await qortalRequest({
action: "PUBLISH_QDN_RESOURCE",
service: "JSON",
identifier: identifier,
base64: toBase64,
});
if (res?.signature) {
showSuccess("Successfully published index");
publish.updatePublish(
{
identifier,
service: "JSON",
name: username,
},
objectToPublish
);
setTerms([]);
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to publish index";
showError(message);
} finally {
dismissToast(loadId);
}
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setRecommendedSelection((event.target as HTMLInputElement).value);
};
return (
<>
<DialogContent>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "20px",
width: "100%",
alignItems: "flex-start",
}}
>
<IconButton disabled={mode === 1} onClick={() => setMode(1)}>
<ArrowBackIosIcon />
</IconButton>
{recommendedIndices?.length > 0 && (
<>
<Typography>Recommended Indices</Typography>
<RadioGroup
aria-labelledby="demo-controlled-radio-buttons-group"
name="controlled-radio-buttons-group"
value={recommendedSelection}
onChange={handleChange}
>
{recommendedIndices?.map((ri: any, i)=> {
return <FormControlLabel key={i} value={ri?.term} control={<Radio />} label={ri?.term} />
})}
</RadioGroup>
<Divider sx={{
width: '100%'
}} />
</>
)}
<Box>
<RadioGroup
aria-labelledby="demo-controlled-radio-buttons-group"
name="controlled-radio-buttons-group"
value={recommendedSelection}
onChange={handleChange}
>
<FormControlLabel value="" control={<Radio />} label="Add search term" />
</RadioGroup>
<Spacer height="10px" />
{!recommendedSelection && (
<OptionsManager
maxLength={17}
items={terms}
onlyStrings
label="search terms"
setItems={async (termsNew: string[]) => {
try {
if (terms?.length === 1 && termsNew?.length === 2) {
await show({
message: "",
});
}
setTerms(termsNew);
} catch (error) {
//error
}
}}
/>
)}
<Spacer height="10px" />
<Typography
sx={{
visibility:
shouldRecommendMax && fullSize > 230 ? "visible" : "hidden",
}}
>
It is recommended to keep your term character count below{" "}
{recommendedSize} characters
</Typography>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={createIndex}
disabled={disableButton}
variant="contained"
>
Publish index
</Button>
</DialogActions>
<Dialog
open={isShow}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
sx={{
zIndex: 999991,
}}
>
<DialogTitle id="alert-dialog-title">
Adding multiple indices
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
Subsequent indices will keep your publish fees lower, but they will
have less strength in future search results.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onCancel}>Cancel</Button>
<Button onClick={onOk}>
Continue
</Button>
</DialogActions>
</Dialog>
</>
);
};
const YourIndices = ({
link,
name,
mode,
setMode,
username,
category,
rootName,
}: PropsCreateIndex) => {
const [terms, setTerms] = useState<string[]>([]);
const publish = usePublish();
const [size, setSize] = useState(0);
const [fullSize, setFullSize] = useState(0);
const { isShow, onCancel, onOk, show } = useModal();
const [recommendedIndices, setRecommendedIndices] = useState([])
const [recommendedSelection, setRecommendedSelection] = useState("")
const objectToPublish = useMemo(() => {
if(recommendedSelection){
return [
{
n: name,
t: recommendedSelection.toLocaleLowerCase(),
c: category,
l: link,
}
]
}
return terms?.map((term) => {
return {
n: name,
t: term.toLocaleLowerCase(),
c: category,
l: link,
};
});
}, [name, terms, category, link, recommendedSelection]);
const objectToCalculateSize = useMemo(() => {
return [
{
n: name,
t: "",
c: category,
l: link,
},
];
}, [name, category, link]);
const shouldRecommendMax = !recommendedSelection && terms?.length === 1 && 230 - size > 50;
const recommendedSize = 230 - size;
useEffect(() => {
const getSize = async (data: any, data2: any) => {
try {
const toBase64 = await objectToBase64(data);
const size = toBase64?.length;
setSize(size);
const toBase642 = await objectToBase64(data2);
const size2 = toBase642?.length;
setFullSize(size2);
} catch (error) {}
};
getSize(objectToCalculateSize, objectToPublish);
}, [objectToCalculateSize, objectToPublish]);
const getYourIndices = useCallback(async (nameParam: string, linkParam: string, rootNameParam: string)=> {
try {
const hashedRootName = await hashWordWithoutPublicSalt(rootNameParam, 20);
const hashedLink = await hashWordWithoutPublicSalt(linkParam, 20);
const identifier = `idx-${hashedRootName}-${hashedLink}-`;
const res = await fetch(`/arbitrary/indices/${nameParam}/${identifier}`)
const data = await res.json()
setRecommendedIndices(data)
} catch (error) {
}
}, [])
useEffect(()=> {
if(username && link && rootName){
getYourIndices(username, link, rootName)
}
}, [username, link, rootName, getYourIndices])
const disableButton = (terms.length === 0 && !recommendedSelection) || !name || !link;
const createIndex = async () => {
const loadId = showLoading("Publishing index...");
try {
const hashedRootName = await hashWordWithoutPublicSalt(rootName, 20);
const hashedLink = await hashWordWithoutPublicSalt(link, 20);
const randomUid = uid.rnd();
const identifier = `idx-${hashedRootName}-${hashedLink}-${randomUid}`;
const toBase64 = await objectToBase64(objectToPublish);
const res = await qortalRequest({
action: "PUBLISH_QDN_RESOURCE",
service: "JSON",
identifier: identifier,
base64: toBase64,
});
if (res?.signature) {
showSuccess("Successfully published index");
publish.updatePublish(
{
identifier,
service: "JSON",
name: username,
},
objectToPublish
);
setTerms([]);
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to publish index";
showError(message);
} finally {
dismissToast(loadId);
}
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setRecommendedSelection((event.target as HTMLInputElement).value);
};
return (
<>
<DialogContent>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "20px",
width: "100%",
alignItems: "flex-start",
}}
>
<IconButton disabled={mode === 1} onClick={() => setMode(1)}>
<ArrowBackIosIcon />
</IconButton>
{recommendedIndices?.length > 0 && (
<>
<Typography>Recommended Indices</Typography>
<RadioGroup
aria-labelledby="demo-controlled-radio-buttons-group"
name="controlled-radio-buttons-group"
value={recommendedSelection}
onChange={handleChange}
>
{recommendedIndices?.map((ri: any, i)=> {
return <FormControlLabel key={i} value={ri?.term} control={<Radio />} label={ri?.term} />
})}
</RadioGroup>
<Divider sx={{
width: '100%'
}} />
</>
)}
<Box>
<RadioGroup
aria-labelledby="demo-controlled-radio-buttons-group"
name="controlled-radio-buttons-group"
value={recommendedSelection}
onChange={handleChange}
>
<FormControlLabel value="" control={<Radio />} label="Add search term" />
</RadioGroup>
<Spacer height="10px" />
{!recommendedSelection && (
<OptionsManager
maxLength={17}
items={terms}
onlyStrings
label="search terms"
setItems={async (termsNew: string[]) => {
try {
if (terms?.length === 1 && termsNew?.length === 2) {
await show({
message: "",
});
}
setTerms(termsNew);
} catch (error) {
//error
}
}}
/>
)}
<Spacer height="10px" />
<Typography
sx={{
visibility:
shouldRecommendMax && fullSize > 230 ? "visible" : "hidden",
}}
>
It is recommended to keep your term character count below{" "}
{recommendedSize} characters
</Typography>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={createIndex}
disabled={disableButton}
variant="contained"
>
Publish index
</Button>
</DialogActions>
<Dialog
open={isShow}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
sx={{
zIndex: 999991,
}}
>
<DialogTitle id="alert-dialog-title">
Adding multiple indices
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
Subsequent indices will keep your publish fees lower, but they will
have less strength in future search results.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onCancel}>Cancel</Button>
<Button onClick={onOk}>
Continue
</Button>
</DialogActions>
</Dialog>
</>
);
};