mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-04 22:40:29 +03:00
Apply requested changes
This commit is contained in:
parent
3cc7c085c2
commit
3eea7dd074
3 changed files with 197 additions and 169 deletions
|
|
@ -11,7 +11,7 @@ export const useExploreSelected = (): boolean => {
|
||||||
return !!match;
|
return !!match;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useExploreFeaturedRooms = (): boolean => {
|
export const useExploreFeaturedSelected = (): boolean => {
|
||||||
const match = useMatch({
|
const match = useMatch({
|
||||||
path: getExploreFeaturedPath(),
|
path: getExploreFeaturedPath(),
|
||||||
caseSensitive: true,
|
caseSensitive: true,
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,22 @@ export type InCinnyExploreServersContent = {
|
||||||
servers?: string[];
|
servers?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useExploreServers = (
|
export const useExploreServers = (): [
|
||||||
exclude?: string[]
|
string[],
|
||||||
): [string[], (server: string) => Promise<void>, (server: string) => Promise<void>] => {
|
(server: string) => Promise<void>,
|
||||||
|
(server: string) => Promise<void>
|
||||||
|
] => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const accountData = useAccountData(AccountDataEvent.CinnyExplore);
|
const accountData = useAccountData(AccountDataEvent.CinnyExplore);
|
||||||
const userAddedServers = useMemo(
|
const userAddedServers = useMemo(
|
||||||
() =>
|
() => accountData?.getContent<InCinnyExploreServersContent>()?.servers ?? [],
|
||||||
accountData
|
[accountData]
|
||||||
?.getContent<InCinnyExploreServersContent>()
|
|
||||||
?.servers?.filter((server) => !exclude?.includes(server)) ?? [],
|
|
||||||
[exclude, accountData]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const addServer = useCallback(
|
const addServer = useCallback(
|
||||||
async (server: string) => {
|
async (server: string) => {
|
||||||
if (userAddedServers.indexOf(server) === -1) {
|
if (userAddedServers.indexOf(server) === -1) {
|
||||||
|
// @ts-expect-error Custom account data event
|
||||||
await mx.setAccountData(AccountDataEvent.CinnyExplore, {
|
await mx.setAccountData(AccountDataEvent.CinnyExplore, {
|
||||||
servers: [...userAddedServers, server],
|
servers: [...userAddedServers, server],
|
||||||
});
|
});
|
||||||
|
|
@ -33,6 +33,7 @@ export const useExploreServers = (
|
||||||
|
|
||||||
const removeServer = useCallback(
|
const removeServer = useCallback(
|
||||||
async (server: string) => {
|
async (server: string) => {
|
||||||
|
// @ts-expect-error Custom account data event
|
||||||
await mx.setAccountData(AccountDataEvent.CinnyExplore, {
|
await mx.setAccountData(AccountDataEvent.CinnyExplore, {
|
||||||
servers: userAddedServers.filter((addedServer) => server !== addedServer),
|
servers: userAddedServers.filter((addedServer) => server !== addedServer),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import {
|
||||||
} from '../../../components/nav';
|
} from '../../../components/nav';
|
||||||
import { getExploreFeaturedPath, getExploreServerPath } from '../../pathUtils';
|
import { getExploreFeaturedPath, getExploreServerPath } from '../../pathUtils';
|
||||||
import {
|
import {
|
||||||
useExploreFeaturedRooms,
|
useExploreFeaturedSelected,
|
||||||
useExploreServer,
|
useExploreServer,
|
||||||
} from '../../../hooks/router/useExploreSelected';
|
} from '../../../hooks/router/useExploreSelected';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
|
@ -44,21 +44,18 @@ import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { useExploreServers } from '../../../hooks/useExploreServers';
|
import { useExploreServers } from '../../../hooks/useExploreServers';
|
||||||
import { useAlive } from '../../../hooks/useAlive';
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
import { useClientConfig } from '../../../hooks/useClientConfig';
|
import { useClientConfig } from '../../../hooks/useClientConfig';
|
||||||
|
import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||||
|
|
||||||
type AddExploreServerPromptProps = {
|
type AddExploreServerPromptProps = {
|
||||||
onSubmit: (server: string, save: boolean) => Promise<void>;
|
onSubmit: (server: string, save: boolean) => Promise<void>;
|
||||||
header: ReactNode;
|
open: boolean;
|
||||||
children: ReactNode;
|
requestClose: () => void;
|
||||||
selected?: boolean;
|
|
||||||
};
|
};
|
||||||
export function AddExploreServerPrompt({
|
export function AddExploreServerPrompt({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
header,
|
open,
|
||||||
children,
|
requestClose,
|
||||||
selected = false,
|
|
||||||
}: AddExploreServerPromptProps) {
|
}: AddExploreServerPromptProps) {
|
||||||
const mx = useMatrixClient();
|
|
||||||
const [dialog, setDialog] = useState(false);
|
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
const serverInputRef = useRef<HTMLInputElement>(null);
|
const serverInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
|
@ -74,13 +71,12 @@ export function AddExploreServerPrompt({
|
||||||
const server = getInputServer();
|
const server = getInputServer();
|
||||||
if (!server) return;
|
if (!server) return;
|
||||||
|
|
||||||
await mx.publicRooms({ server, limit: 1 });
|
|
||||||
await onSubmit(server, save);
|
await onSubmit(server, save);
|
||||||
if (alive()) {
|
if (alive()) {
|
||||||
setDialog(false);
|
requestClose();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[alive, onSubmit, mx]
|
[onSubmit, alive, requestClose]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [viewState, handleView] = useAsyncCallback(() => submit(false));
|
const [viewState, handleView] = useAsyncCallback(() => submit(false));
|
||||||
|
|
@ -89,113 +85,91 @@ export function AddExploreServerPrompt({
|
||||||
viewState.status === AsyncStatus.Loading || saveViewState.status === AsyncStatus.Loading;
|
viewState.status === AsyncStatus.Loading || saveViewState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Overlay open={open} backdrop={<OverlayBackdrop />}>
|
||||||
<Overlay open={dialog} backdrop={<OverlayBackdrop />}>
|
<OverlayCenter>
|
||||||
<OverlayCenter>
|
<FocusTrap
|
||||||
<FocusTrap
|
focusTrapOptions={{
|
||||||
focusTrapOptions={{
|
initialFocus: false,
|
||||||
initialFocus: false,
|
clickOutsideDeactivates: true,
|
||||||
clickOutsideDeactivates: true,
|
onDeactivate: requestClose,
|
||||||
onDeactivate: () => setDialog(false),
|
escapeDeactivates: stopPropagation,
|
||||||
escapeDeactivates: stopPropagation,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Dialog variant="Surface">
|
||||||
<Dialog variant="Surface">
|
<Header
|
||||||
<Header
|
style={{
|
||||||
style={{
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
borderBottomWidth: config.borderWidth.B300,
|
}}
|
||||||
}}
|
variant="Surface"
|
||||||
variant="Surface"
|
size="500"
|
||||||
size="500"
|
>
|
||||||
>
|
<Box grow="Yes">
|
||||||
<Box grow="Yes">{header}</Box>
|
<Text size="H4">Add Server</Text>
|
||||||
<IconButton size="300" onClick={() => setDialog(false)} radii="300">
|
|
||||||
<Icon src={Icons.Cross} />
|
|
||||||
</IconButton>
|
|
||||||
</Header>
|
|
||||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
|
||||||
<Text priority="400">Add server name to explore public communities.</Text>
|
|
||||||
<Box direction="Column" gap="100">
|
|
||||||
<Text size="L400">Server Name</Text>
|
|
||||||
<Input ref={serverInputRef} name="serverInput" variant="Background" required />
|
|
||||||
{viewState.status === AsyncStatus.Error && (
|
|
||||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
|
||||||
Failed to load public rooms. Please try again.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Box direction="Column" gap="200">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
onClick={handleView}
|
|
||||||
variant="Secondary"
|
|
||||||
fill="Soft"
|
|
||||||
before={
|
|
||||||
viewState.status === AsyncStatus.Loading && (
|
|
||||||
<Spinner fill="Solid" variant="Secondary" size="200" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
<Text size="B400">View</Text>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
onClick={handleSaveView}
|
|
||||||
variant="Primary"
|
|
||||||
fill="Soft"
|
|
||||||
before={
|
|
||||||
saveViewState.status === AsyncStatus.Loading && (
|
|
||||||
<Spinner fill="Solid" variant="Secondary" size="200" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
<Text size="B400">Save & View</Text>
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Dialog>
|
<IconButton size="300" onClick={requestClose} radii="300">
|
||||||
</FocusTrap>
|
<Icon src={Icons.Cross} />
|
||||||
</OverlayCenter>
|
</IconButton>
|
||||||
</Overlay>
|
</Header>
|
||||||
<NavItem variant="Background" aria-selected={selected}>
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
<NavButton onClick={() => setDialog(true)}>
|
<Text priority="400">Add server name to explore public communities.</Text>
|
||||||
<NavItemContent>{children}</NavItemContent>
|
<Box direction="Column" gap="100">
|
||||||
</NavButton>
|
<Text size="L400">Server Name</Text>
|
||||||
</NavItem>
|
<Input ref={serverInputRef} name="serverInput" variant="Background" required />
|
||||||
</>
|
{viewState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
|
Failed to load public rooms. Please try again.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
onClick={handleView}
|
||||||
|
variant="Primary"
|
||||||
|
fill="Soft"
|
||||||
|
before={
|
||||||
|
viewState.status === AsyncStatus.Loading && (
|
||||||
|
<Spinner fill="Soft" variant="Secondary" size="200" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Text size="B400">View</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
onClick={handleSaveView}
|
||||||
|
variant="Primary"
|
||||||
|
fill="Solid"
|
||||||
|
before={
|
||||||
|
saveViewState.status === AsyncStatus.Loading && (
|
||||||
|
<Spinner fill="Solid" variant="Secondary" size="200" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Text size="B400">Save & View</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExploreServerNavItemAction = {
|
|
||||||
onClick: () => Promise<void>;
|
|
||||||
icon: IconSrc;
|
|
||||||
alwaysVisible: boolean;
|
|
||||||
};
|
|
||||||
type ExploreServerNavItemProps = {
|
type ExploreServerNavItemProps = {
|
||||||
server: string;
|
server: string;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
icon: IconSrc;
|
icon: IconSrc;
|
||||||
action?: ExploreServerNavItemAction;
|
after?: (hover: boolean) => ReactNode;
|
||||||
};
|
};
|
||||||
export function ExploreServerNavItem({
|
export function ExploreServerNavItem({ server, selected, icon, after }: ExploreServerNavItemProps) {
|
||||||
server,
|
|
||||||
selected,
|
|
||||||
icon,
|
|
||||||
action,
|
|
||||||
}: ExploreServerNavItemProps) {
|
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
const { hoverProps } = useHover({ onHoverChange: setHover });
|
const { hoverProps } = useHover({ onHoverChange: setHover });
|
||||||
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
||||||
const [actionState, actionCallback] = useAsyncCallback(
|
|
||||||
useCallback(async () => {
|
|
||||||
await action?.onClick();
|
|
||||||
}, [action])
|
|
||||||
);
|
|
||||||
const actionInProgress =
|
|
||||||
actionState.status === AsyncStatus.Loading || actionState.status === AsyncStatus.Success;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavItem
|
<NavItem
|
||||||
|
|
@ -219,7 +193,7 @@ export function ExploreServerNavItem({
|
||||||
</Box>
|
</Box>
|
||||||
</NavItemContent>
|
</NavItemContent>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
{action !== undefined && (hover || actionInProgress || action.alwaysVisible) && (
|
{/* {action !== undefined && (hover || actionInProgress || action.alwaysVisible) && (
|
||||||
<NavItemOptions>
|
<NavItemOptions>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={actionCallback}
|
onClick={actionCallback}
|
||||||
|
|
@ -236,7 +210,8 @@ export function ExploreServerNavItem({
|
||||||
)}
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</NavItemOptions>
|
</NavItemOptions>
|
||||||
)}
|
)} */}
|
||||||
|
{after && <NavItemOptions>{after(hover)}</NavItemOptions>}
|
||||||
</NavItem>
|
</NavItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -253,10 +228,10 @@ export function Explore() {
|
||||||
clientConfig.featuredCommunities?.servers?.filter((server) => server !== userServer) ?? [],
|
clientConfig.featuredCommunities?.servers?.filter((server) => server !== userServer) ?? [],
|
||||||
[clientConfig, userServer]
|
[clientConfig, userServer]
|
||||||
);
|
);
|
||||||
const [exploreServers, addServer, removeServer] = useExploreServers(featuredServers);
|
const [exploreServers, addServer, removeServer] = useExploreServers();
|
||||||
|
|
||||||
const selectedServer = useExploreServer();
|
const selectedServer = useExploreServer();
|
||||||
const exploringFeaturedRooms = useExploreFeaturedRooms();
|
const exploringFeaturedRooms = useExploreFeaturedSelected();
|
||||||
const exploringUnlistedServer = useMemo(
|
const exploringUnlistedServer = useMemo(
|
||||||
() =>
|
() =>
|
||||||
!(
|
!(
|
||||||
|
|
@ -268,21 +243,25 @@ export function Explore() {
|
||||||
[exploreServers, selectedServer, userServer, featuredServers]
|
[exploreServers, selectedServer, userServer, featuredServers]
|
||||||
);
|
);
|
||||||
|
|
||||||
const addServerCallback = useCallback(
|
const [addServerState, addServerCallback] = useAsyncCallback(
|
||||||
async (server: string, save: boolean) => {
|
useCallback(
|
||||||
if (save && server !== userServer && !featuredServers.includes(server) && selectedServer) {
|
async (server: string, save: boolean) => {
|
||||||
await addServer(server);
|
if (save && server !== userServer && !featuredServers.includes(server) && selectedServer) {
|
||||||
}
|
await addServer(server);
|
||||||
navigate(getExploreServerPath(server));
|
}
|
||||||
},
|
navigate(getExploreServerPath(server));
|
||||||
[addServer, navigate, userServer, featuredServers, selectedServer]
|
},
|
||||||
|
[addServer, navigate, userServer, featuredServers, selectedServer]
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeServerCallback = useCallback(
|
const [removeServerState, removeServerCallback] = useAsyncCallback(
|
||||||
async (server: string) => {
|
useCallback(
|
||||||
await removeServer(server);
|
async (server: string) => {
|
||||||
},
|
await removeServer(server);
|
||||||
[removeServer]
|
},
|
||||||
|
[removeServer]
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -320,19 +299,7 @@ export function Explore() {
|
||||||
<ExploreServerNavItem
|
<ExploreServerNavItem
|
||||||
server={userServer}
|
server={userServer}
|
||||||
selected={userServer === selectedServer}
|
selected={userServer === selectedServer}
|
||||||
icon={Icons.Home}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{exploringUnlistedServer && selectedServer !== undefined && (
|
|
||||||
<ExploreServerNavItem
|
|
||||||
server={selectedServer}
|
|
||||||
selected
|
|
||||||
icon={Icons.Server}
|
icon={Icons.Server}
|
||||||
action={{
|
|
||||||
alwaysVisible: true,
|
|
||||||
icon: Icons.Plus,
|
|
||||||
onClick: () => addServerCallback(selectedServer, true),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
|
|
@ -350,34 +317,94 @@ export function Explore() {
|
||||||
icon={Icons.Server}
|
icon={Icons.Server}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{exploreServers.map((server) => (
|
{exploreServers
|
||||||
|
.filter((server) => !featuredServers.includes(server))
|
||||||
|
.map((server) => (
|
||||||
|
<ExploreServerNavItem
|
||||||
|
key={server}
|
||||||
|
server={server}
|
||||||
|
selected={server === selectedServer}
|
||||||
|
icon={Icons.Server}
|
||||||
|
after={(hover) => {
|
||||||
|
const busy = removeServerState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
if (!(hover || busy)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => removeServerCallback(server)}
|
||||||
|
variant="Background"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Spinner variant="Secondary" fill="Solid" size="200" />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={Icons.Minus} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{exploringUnlistedServer && selectedServer !== undefined && (
|
||||||
<ExploreServerNavItem
|
<ExploreServerNavItem
|
||||||
key={server}
|
server={selectedServer}
|
||||||
server={server}
|
selected
|
||||||
selected={server === selectedServer}
|
|
||||||
icon={Icons.Server}
|
icon={Icons.Server}
|
||||||
action={{
|
after={() => {
|
||||||
alwaysVisible: false,
|
const busy = addServerState.status === AsyncStatus.Loading;
|
||||||
icon: Icons.Minus,
|
|
||||||
onClick: () => removeServerCallback(server),
|
return (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => addServerCallback(selectedServer, true)}
|
||||||
|
variant="Background"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Spinner variant="Secondary" fill="Solid" size="200" />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={Icons.Bookmark} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
<AddExploreServerPrompt
|
<UseStateProvider initial={false}>
|
||||||
onSubmit={addServerCallback}
|
{(dialogOpen, setDialogOpen) => (
|
||||||
header={<Text size="H4">Add Server</Text>}
|
<>
|
||||||
>
|
<NavItem variant="Background">
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
<NavButton onClick={() => setDialogOpen(true)}>
|
||||||
<Avatar size="200" radii="400">
|
<NavItemContent>
|
||||||
<Icon src={Icons.Plus} size="100" />
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
</Avatar>
|
<Avatar size="200" radii="400">
|
||||||
<Box as="span" grow="Yes">
|
<Icon src={Icons.Plus} size="100" />
|
||||||
<Text as="span" size="Inherit" truncate>
|
</Avatar>
|
||||||
Add Server
|
<Box as="span" grow="Yes">
|
||||||
</Text>
|
<Text as="span" size="Inherit" truncate>
|
||||||
</Box>
|
Add Server
|
||||||
</Box>
|
</Text>
|
||||||
</AddExploreServerPrompt>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</NavItemContent>
|
||||||
|
</NavButton>
|
||||||
|
</NavItem>
|
||||||
|
<AddExploreServerPrompt
|
||||||
|
onSubmit={addServerCallback}
|
||||||
|
open={dialogOpen}
|
||||||
|
requestClose={() => setDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</UseStateProvider>
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
</Box>
|
</Box>
|
||||||
</PageNavContent>
|
</PageNavContent>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue