mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-03 00:33:49 +03:00
feat: adding background to item
This commit is contained in:
parent
f6072eeb5c
commit
1f2f269736
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@ -67,8 +67,6 @@ jobs:
|
|||||||
dist/*.exe
|
dist/*.exe
|
||||||
dist/*.zip
|
dist/*.zip
|
||||||
dist/*.dmg
|
dist/*.dmg
|
||||||
dist/*.AppImage
|
|
||||||
dist/*.snap
|
|
||||||
dist/*.deb
|
dist/*.deb
|
||||||
dist/*.rpm
|
dist/*.rpm
|
||||||
dist/*.tar.gz
|
dist/*.tar.gz
|
||||||
|
@ -82,7 +82,6 @@
|
|||||||
"repacks_modal_description": "Choose the repack you want to download",
|
"repacks_modal_description": "Choose the repack you want to download",
|
||||||
"downloads_path": "Letöltések helye",
|
"downloads_path": "Letöltések helye",
|
||||||
"select_folder_hint": "Ahhoz, hogy megváltoztasd a helyet, hozzákell férned a",
|
"select_folder_hint": "Ahhoz, hogy megváltoztasd a helyet, hozzákell férned a",
|
||||||
"hydra_settings": "Hydra beállítások",
|
|
||||||
"download_now": "Töltsd le most"
|
"download_now": "Töltsd le most"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
|
@ -50,5 +50,7 @@ export const databasePath = path.join(
|
|||||||
"hydra.db"
|
"hydra.db"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const imageCachePath = path.join(app.getPath("userData"), ".imagecache");
|
||||||
|
|
||||||
export const INSTALLATION_ID_LENGTH = 6;
|
export const INSTALLATION_ID_LENGTH = 6;
|
||||||
export const ACTIVATION_KEY_MULTIPLIER = 7;
|
export const ACTIVATION_KEY_MULTIPLIER = 7;
|
||||||
|
@ -2,7 +2,6 @@ import { DataSource } from "typeorm";
|
|||||||
import {
|
import {
|
||||||
Game,
|
Game,
|
||||||
GameShopCache,
|
GameShopCache,
|
||||||
ImageCache,
|
|
||||||
Repack,
|
Repack,
|
||||||
RepackerFriendlyName,
|
RepackerFriendlyName,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
@ -19,7 +18,6 @@ export const createDataSource = (options: Partial<SqliteConnectionOptions>) =>
|
|||||||
database: databasePath,
|
database: databasePath,
|
||||||
entities: [
|
entities: [
|
||||||
Game,
|
Game,
|
||||||
ImageCache,
|
|
||||||
Repack,
|
Repack,
|
||||||
RepackerFriendlyName,
|
RepackerFriendlyName,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
|
@ -1,25 +0,0 @@
|
|||||||
import {
|
|
||||||
Entity,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from "typeorm";
|
|
||||||
|
|
||||||
@Entity("image_cache")
|
|
||||||
export class ImageCache {
|
|
||||||
@PrimaryGeneratedColumn()
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
@Column("text", { unique: true })
|
|
||||||
url: string;
|
|
||||||
|
|
||||||
@Column("text")
|
|
||||||
data: string;
|
|
||||||
|
|
||||||
@CreateDateColumn()
|
|
||||||
createdAt: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn()
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
@ -1,32 +1,35 @@
|
|||||||
import { imageCacheRepository } from "@main/repository";
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { getImageBase64 } from "@main/helpers";
|
import { getFileBuffer } from "@main/helpers";
|
||||||
import { logger } from "@main/services";
|
import { logger } from "@main/services";
|
||||||
|
import { imageCachePath } from "@main/constants";
|
||||||
|
|
||||||
const getOrCacheImage = async (
|
const getOrCacheImage = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
url: string
|
url: string
|
||||||
) => {
|
) => {
|
||||||
const cache = await imageCacheRepository.findOne({
|
if (!fs.existsSync(imageCachePath)) fs.mkdirSync(imageCachePath);
|
||||||
where: {
|
|
||||||
url,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (cache) return cache.data;
|
const extname = path.extname(url);
|
||||||
|
|
||||||
getImageBase64(url).then((data) =>
|
const checksum = crypto.createHash("sha256").update(url).digest("hex");
|
||||||
imageCacheRepository
|
const cachePath = path.join(imageCachePath, `${checksum}${extname}`);
|
||||||
.save({
|
|
||||||
url,
|
const cache = fs.existsSync(cachePath);
|
||||||
data,
|
|
||||||
})
|
if (cache) return `hydra://${cachePath}`;
|
||||||
.catch(() => {
|
|
||||||
logger.error(`Failed to cache image "${url}"`, {
|
getFileBuffer(url).then((buffer) =>
|
||||||
|
fs.writeFile(cachePath, buffer, (err) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error(`Failed to cache image`, err, {
|
||||||
method: "getOrCacheImage",
|
method: "getOrCacheImage",
|
||||||
});
|
});
|
||||||
})
|
}
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
|
@ -74,12 +74,15 @@ export const getSteamAppAsset = (
|
|||||||
return `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectID}/${clientIcon}.ico`;
|
return `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectID}/${clientIcon}.ico`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getImageBase64 = async (url: string) =>
|
export const getFileBuffer = async (url: string) =>
|
||||||
fetch(url, { method: "GET" }).then((response) =>
|
fetch(url, { method: "GET" }).then((response) =>
|
||||||
response.arrayBuffer().then((buffer) => {
|
response.arrayBuffer().then((buffer) => Buffer.from(buffer))
|
||||||
return `data:image/jpeg;base64,${Buffer.from(buffer).toString("base64")}`;
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getImageBase64 = async (url: string) =>
|
||||||
|
getFileBuffer(url).then((buffer) => {
|
||||||
|
return `data:image/jpeg;base64,${Buffer.from(buffer).toString("base64")}`;
|
||||||
|
});
|
||||||
|
|
||||||
export * from "./formatters";
|
export * from "./formatters";
|
||||||
export * from "./ps";
|
export * from "./ps";
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow } from "electron";
|
import { app, BrowserWindow, net, protocol } from "electron";
|
||||||
import { init } from "@sentry/electron/main";
|
import { init } from "@sentry/electron/main";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@ -52,6 +52,10 @@ if (process.defaultApp) {
|
|||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
electronApp.setAppUserModelId("site.hydralauncher.hydra");
|
electronApp.setAppUserModelId("site.hydralauncher.hydra");
|
||||||
|
|
||||||
|
protocol.handle("hydra", (request) =>
|
||||||
|
net.fetch("file://" + request.url.slice("hydra://".length))
|
||||||
|
);
|
||||||
|
|
||||||
dataSource.initialize().then(async () => {
|
dataSource.initialize().then(async () => {
|
||||||
await resolveDatabaseUpdates();
|
await resolveDatabaseUpdates();
|
||||||
|
|
||||||
|
@ -2,7 +2,6 @@ import { dataSource } from "./data-source";
|
|||||||
import {
|
import {
|
||||||
Game,
|
Game,
|
||||||
GameShopCache,
|
GameShopCache,
|
||||||
ImageCache,
|
|
||||||
Repack,
|
Repack,
|
||||||
RepackerFriendlyName,
|
RepackerFriendlyName,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
@ -12,8 +11,6 @@ import {
|
|||||||
|
|
||||||
export const gameRepository = dataSource.getRepository(Game);
|
export const gameRepository = dataSource.getRepository(Game);
|
||||||
|
|
||||||
export const imageCacheRepository = dataSource.getRepository(ImageCache);
|
|
||||||
|
|
||||||
export const repackRepository = dataSource.getRepository(Repack);
|
export const repackRepository = dataSource.getRepository(Repack);
|
||||||
|
|
||||||
export const repackerFriendlyNameRepository =
|
export const repackerFriendlyNameRepository =
|
||||||
|
@ -1,16 +1,12 @@
|
|||||||
import { parentPort } from "worker_threads";
|
import { parentPort } from "worker_threads";
|
||||||
import parseTorrent from "parse-torrent";
|
import parseTorrent from "parse-torrent";
|
||||||
|
import { getFileBuffer } from "@main/helpers";
|
||||||
|
|
||||||
const port = parentPort;
|
const port = parentPort;
|
||||||
if (!port) throw new Error("IllegalState");
|
if (!port) throw new Error("IllegalState");
|
||||||
|
|
||||||
const getTorrentBuffer = (url: string) =>
|
|
||||||
fetch(url, { method: "GET" }).then((response) =>
|
|
||||||
response.arrayBuffer().then((buffer) => Buffer.from(buffer))
|
|
||||||
);
|
|
||||||
|
|
||||||
port.on("message", async (url: string) => {
|
port.on("message", async (url: string) => {
|
||||||
const buffer = await getTorrentBuffer(url);
|
const buffer = await getFileBuffer(url);
|
||||||
const torrent = await parseTorrent(buffer);
|
const torrent = await parseTorrent(buffer);
|
||||||
|
|
||||||
port.postMessage(torrent);
|
port.postMessage(torrent);
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
<title>Hydra</title>
|
<title>Hydra</title>
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' data: https://cdn2.steamgriddb.com;"
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: hydra: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com;"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body style="background-color: #1c1c1c">
|
<body style="background-color: #1c1c1c">
|
||||||
|
@ -52,7 +52,7 @@ export const menu = style({
|
|||||||
listStyle: "none",
|
listStyle: "none",
|
||||||
padding: "0",
|
padding: "0",
|
||||||
margin: "0",
|
margin: "0",
|
||||||
gap: `${SPACING_UNIT * 2}px`,
|
gap: `${SPACING_UNIT / 2}px`,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
@ -64,16 +64,16 @@ export const menuItem = recipe({
|
|||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
textWrap: "nowrap",
|
textWrap: "nowrap",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
opacity: "0.9",
|
|
||||||
color: vars.color.muted,
|
color: vars.color.muted,
|
||||||
|
borderRadius: "4px",
|
||||||
":hover": {
|
":hover": {
|
||||||
opacity: "1",
|
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
active: {
|
active: {
|
||||||
true: {
|
true: {
|
||||||
opacity: "1",
|
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -96,6 +96,7 @@ export const menuItemButton = style({
|
|||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
padding: `9px ${SPACING_UNIT}px`,
|
||||||
selectors: {
|
selectors: {
|
||||||
[`${menuItem({ active: true }).split(" ")[1]} &`]: {
|
[`${menuItem({ active: true }).split(" ")[1]} &`]: {
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
@ -120,20 +121,12 @@ export const sectionTitle = style({
|
|||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const section = recipe({
|
export const section = style({
|
||||||
base: {
|
padding: `${SPACING_UNIT * 2}px 0`,
|
||||||
padding: `${SPACING_UNIT * 2}px 0`,
|
gap: `${SPACING_UNIT * 2}px`,
|
||||||
gap: `${SPACING_UNIT * 2}px`,
|
display: "flex",
|
||||||
display: "flex",
|
flexDirection: "column",
|
||||||
flexDirection: "column",
|
paddingBottom: `${SPACING_UNIT}px`,
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
hasBorder: {
|
|
||||||
true: {
|
|
||||||
borderBottom: `solid 1px ${vars.color.border}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sidebarFooter = style({
|
export const sidebarFooter = style({
|
||||||
|
@ -6,7 +6,6 @@ import type { Game } from "@types";
|
|||||||
|
|
||||||
import { AsyncImage, TextField } from "@renderer/components";
|
import { AsyncImage, TextField } from "@renderer/components";
|
||||||
import { useDownload, useLibrary } from "@renderer/hooks";
|
import { useDownload, useLibrary } from "@renderer/hooks";
|
||||||
import { SPACING_UNIT } from "../../theme.css";
|
|
||||||
|
|
||||||
import { routes } from "./routes";
|
import { routes } from "./routes";
|
||||||
|
|
||||||
@ -15,6 +14,7 @@ import DiscordLogo from "@renderer/assets/discord-icon.svg?react";
|
|||||||
import XLogo from "@renderer/assets/x-icon.svg?react";
|
import XLogo from "@renderer/assets/x-icon.svg?react";
|
||||||
|
|
||||||
import * as styles from "./sidebar.css";
|
import * as styles from "./sidebar.css";
|
||||||
|
import { vars } from "@renderer/theme.css";
|
||||||
|
|
||||||
const SIDEBAR_MIN_WIDTH = 200;
|
const SIDEBAR_MIN_WIDTH = 200;
|
||||||
const SIDEBAR_INITIAL_WIDTH = 250;
|
const SIDEBAR_INITIAL_WIDTH = 250;
|
||||||
@ -95,7 +95,7 @@ export function Sidebar() {
|
|||||||
}, [library]);
|
}, [library]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.onmousemove = (event) => {
|
window.onmousemove = (event: MouseEvent) => {
|
||||||
if (isResizing) {
|
if (isResizing) {
|
||||||
const cursorXDelta = event.screenX - cursorPos.current.x;
|
const cursorXDelta = event.screenX - cursorPos.current.x;
|
||||||
const newWidth = Math.max(
|
const newWidth = Math.max(
|
||||||
@ -165,11 +165,9 @@ export function Sidebar() {
|
|||||||
macos: window.electron.platform === "darwin",
|
macos: window.electron.platform === "darwin",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{window.electron.platform === "darwin" && (
|
{window.electron.platform === "darwin" && <h2>Hydra</h2>}
|
||||||
<h2 style={{ marginBottom: SPACING_UNIT }}>Hydra</h2>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section className={styles.section({ hasBorder: false })}>
|
<section className={styles.section}>
|
||||||
<ul className={styles.menu}>
|
<ul className={styles.menu}>
|
||||||
{routes.map(({ nameKey, path, render }) => (
|
{routes.map(({ nameKey, path, render }) => (
|
||||||
<li
|
<li
|
||||||
@ -191,7 +189,7 @@ export function Sidebar() {
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={styles.section({ hasBorder: false })}>
|
<section className={styles.section}>
|
||||||
<small className={styles.sectionTitle}>{t("my_library")}</small>
|
<small className={styles.sectionTitle}>{t("my_library")}</small>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
|
@ -13,7 +13,7 @@ export function GameDetailsSkeleton() {
|
|||||||
<div className={styles.hero}>
|
<div className={styles.hero}>
|
||||||
<Skeleton className={styles.heroImageSkeleton} />
|
<Skeleton className={styles.heroImageSkeleton} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.descriptionHeader}>
|
<div className={styles.heroPanelSkeleton}>
|
||||||
<section className={styles.descriptionHeaderInfo}>
|
<section className={styles.descriptionHeaderInfo}>
|
||||||
<Skeleton width={155} />
|
<Skeleton width={155} />
|
||||||
<Skeleton width={135} />
|
<Skeleton width={135} />
|
||||||
|
@ -174,7 +174,6 @@ export const descriptionHeader = style({
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: vars.color.background,
|
backgroundColor: vars.color.background,
|
||||||
borderBottom: `solid 1px ${vars.color.border}`,
|
|
||||||
height: "72px",
|
height: "72px",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -233,6 +232,16 @@ export const randomizerButton = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const heroPanelSkeleton = style({
|
||||||
|
width: "100%",
|
||||||
|
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: vars.color.background,
|
||||||
|
height: "72px",
|
||||||
|
borderBottom: `solid 1px ${vars.color.border}`,
|
||||||
|
});
|
||||||
|
|
||||||
globalStyle(".bb_tag", {
|
globalStyle(".bb_tag", {
|
||||||
marginTop: `${SPACING_UNIT * 2}px`,
|
marginTop: `${SPACING_UNIT * 2}px`,
|
||||||
marginBottom: `${SPACING_UNIT * 2}px`,
|
marginBottom: `${SPACING_UNIT * 2}px`,
|
||||||
|
@ -10,7 +10,6 @@ export const panel = style({
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
transition: "all ease 0.2s",
|
transition: "all ease 0.2s",
|
||||||
borderBottom: `solid 1px ${vars.color.border}`,
|
borderBottom: `solid 1px ${vars.color.border}`,
|
||||||
color: "#8e919b",
|
|
||||||
boxShadow: "0px 0px 15px 0px #000000",
|
boxShadow: "0px 0px 15px 0px #000000",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ export function DODIInstallationGuide({
|
|||||||
}: DODIInstallationGuideProps) {
|
}: DODIInstallationGuideProps) {
|
||||||
const { t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
|
|
||||||
const [dontShowAgain, setDontShowAgain] = useState(true);
|
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (dontShowAgain) {
|
if (dontShowAgain) {
|
||||||
|
@ -22,7 +22,7 @@ export function OnlineFixInstallationGuide({
|
|||||||
const [clipboardLocked, setClipboardLocked] = useState(false);
|
const [clipboardLocked, setClipboardLocked] = useState(false);
|
||||||
const { t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
|
|
||||||
const [dontShowAgain, setDontShowAgain] = useState(true);
|
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||||
|
|
||||||
const handleCopyToClipboard = () => {
|
const handleCopyToClipboard = () => {
|
||||||
setClipboardLocked(true);
|
setClipboardLocked(true);
|
||||||
|
@ -44,12 +44,17 @@ export function RepacksModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
|
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||||
|
const term = event.target.value.toLocaleLowerCase();
|
||||||
|
|
||||||
setFilteredRepacks(
|
setFilteredRepacks(
|
||||||
gameDetails.repacks.filter((repack) =>
|
gameDetails.repacks.filter((repack) => {
|
||||||
repack.title
|
const lowerCaseTitle = repack.title.toLowerCase();
|
||||||
.toLowerCase()
|
const lowerCaseRepacker = repack.repacker.toLowerCase();
|
||||||
.includes(event.target.value.toLocaleLowerCase())
|
|
||||||
)
|
return [lowerCaseTitle, lowerCaseRepacker].some((value) =>
|
||||||
|
value.includes(term)
|
||||||
|
);
|
||||||
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
|||||||
import type { CatalogueEntry } from "@types";
|
import type { CatalogueEntry } from "@types";
|
||||||
|
|
||||||
import type { DebouncedFunc } from "lodash";
|
import type { DebouncedFunc } from "lodash";
|
||||||
import { debounce } from "lodash-es";
|
import { debounce } from "lodash";
|
||||||
|
|
||||||
import { InboxIcon } from "@primer/octicons-react";
|
import { InboxIcon } from "@primer/octicons-react";
|
||||||
import { clearSearch } from "@renderer/features";
|
import { clearSearch } from "@renderer/features";
|
||||||
|
Loading…
Reference in New Issue
Block a user