added site as project. Improved mobile display
This commit is contained in:
@@ -19,7 +19,7 @@ const sans = DM_Sans({
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: SITE.title,
|
title: SITE.title,
|
||||||
description: SITE.description,
|
description: SITE.description
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { Project } from "@/data/content";
|
import type { Project } from "@/data/content";
|
||||||
import SimpleGallery from "./SimpleGallery";
|
import SimpleGallery from "./SimpleGallery";
|
||||||
|
|
||||||
@@ -10,13 +10,14 @@ type Props = {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
setSelected: (selected: any) => void;
|
setSelected: (selected: any) => void;
|
||||||
setExpandedHeight: (activeId: number) => void;
|
updateHeight: (activeId: number, contentRef: HTMLElement | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProjectCard({ project, visible, selected = false, setSelected, setExpandedHeight}: Props) {
|
export default function ProjectCard({ project, visible, selected = false, setSelected, updateHeight}: Props) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const [expandedImage, setExpandedImage] = useState(false);
|
const [expandedImage, setExpandedImage] = useState(false);
|
||||||
const [currentExpandedHeight, setCurrentExpandedHeight] = useState(0);
|
const contentRef = useRef(null);
|
||||||
|
|
||||||
function projectTagsComponent(project: Project){
|
function projectTagsComponent(project: Project){
|
||||||
return (<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }}>
|
return (<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }}>
|
||||||
{project.tags.map((tag) => (
|
{project.tags.map((tag) => (
|
||||||
@@ -40,11 +41,12 @@ export default function ProjectCard({ project, visible, selected = false, setSel
|
|||||||
function notSelectedComponent(){
|
function notSelectedComponent(){
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={contentRef}
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
onClick={() => setSelected(project.id)}
|
onClick={() => setSelected(project.id)}
|
||||||
style={{
|
style={{
|
||||||
height: "100%",
|
// height: "100%",
|
||||||
minWidth: "10em",
|
minWidth: "10em",
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
padding: 28,
|
padding: 28,
|
||||||
@@ -84,14 +86,27 @@ export default function ProjectCard({ project, visible, selected = false, setSel
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contentRef.current) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (contentRef.current) {
|
||||||
|
updateHeight(project.id, contentRef.current);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(contentRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [updateHeight, project.id]);
|
||||||
|
|
||||||
|
|
||||||
function selectedComponent(){
|
function selectedComponent(){
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={contentRef}
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
onClick={() => setSelected(project.id)}
|
|
||||||
style={{
|
style={{
|
||||||
height: "100%",
|
|
||||||
minWidth: "10em",
|
minWidth: "10em",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
@@ -124,11 +139,9 @@ export default function ProjectCard({ project, visible, selected = false, setSel
|
|||||||
{project.description}
|
{project.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{project.images && project.images.length > 0 && (
|
{contentRef.current && project.images && project.images.length > 0 && (
|
||||||
<SimpleGallery images={project.images} videos={project.videos} title={project.title} expansionHandler={(height: number) => {
|
<SimpleGallery images={project.images} videos={project.videos} title={project.title}
|
||||||
setExpandedHeight(height);
|
/>
|
||||||
setExpandedImage(height > 0);
|
|
||||||
}} />
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -143,8 +156,7 @@ export default function ProjectCard({ project, visible, selected = false, setSel
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
if (expandedImage) {
|
if (expandedImage && contentRef.current) {
|
||||||
setExpandedHeight(0);
|
|
||||||
setExpandedImage(false);
|
setExpandedImage(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,97 +2,129 @@
|
|||||||
|
|
||||||
import { PROJECTS } from "@/data/content";
|
import { PROJECTS } from "@/data/content";
|
||||||
import { useStaggerReveal } from "@/hooks/useAnimations";
|
import { useStaggerReveal } from "@/hooks/useAnimations";
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useRef } from "react";
|
||||||
|
|
||||||
import ProjectCard from "@/components/ProjectCard";
|
import ProjectCard from "@/components/ProjectCard";
|
||||||
|
|
||||||
import { Layout, Responsive, useContainerWidth, horizontalCompactor, verticalCompactor, DefaultBreakpoints } from "react-grid-layout";
|
import {
|
||||||
|
Layout,
|
||||||
|
Responsive,
|
||||||
|
useContainerWidth,
|
||||||
|
horizontalCompactor,
|
||||||
|
DefaultBreakpoints,
|
||||||
|
} from "react-grid-layout";
|
||||||
|
|
||||||
import "react-grid-layout/css/styles.css";
|
import "react-grid-layout/css/styles.css";
|
||||||
import "react-resizable/css/styles.css";
|
import "react-resizable/css/styles.css";
|
||||||
|
|
||||||
export default function Projects() {
|
export default function Projects() {
|
||||||
|
const rowHeight = 100;
|
||||||
const rowHeight = 250;
|
|
||||||
const defaultExpandedHeightUnits = 2;
|
|
||||||
const visible = useStaggerReveal(PROJECTS.length, 100);
|
const visible = useStaggerReveal(PROJECTS.length, 100);
|
||||||
const [selectedProject, setSelectedProject] = useState<number>(1);
|
const [selectedProject, setSelectedProject] = useState<number>(1);
|
||||||
|
|
||||||
const { width, containerRef, mounted } = useContainerWidth();
|
const { width, containerRef, mounted } = useContainerWidth();
|
||||||
|
|
||||||
const [topCellHeight, setTopCellHeight] = useState(defaultExpandedHeightUnits);
|
const [layouts, setLayouts] = useState<Record<DefaultBreakpoints, Layout>>(
|
||||||
const [layouts, setLayouts] = useState<Record<DefaultBreakpoints, Layout>>(updateLayout(1));
|
() => buildLayouts(1, {})
|
||||||
|
);
|
||||||
|
|
||||||
|
const heightCache = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
function setExpandedHeight(height: number) {
|
function buildLayouts(
|
||||||
const gridUnits = height === 0 ? defaultExpandedHeightUnits : Math.ceil(height / rowHeight);
|
activeId: number,
|
||||||
setTopCellHeight(gridUnits);
|
heightOverrides: Record<string, number>
|
||||||
setLayouts(updateLayout(selectedProject, gridUnits)); // pass both
|
): Record<DefaultBreakpoints, Layout> {
|
||||||
|
const isSelected = (id: number) => id === activeId;
|
||||||
|
|
||||||
|
function h(project: (typeof PROJECTS)[number]) {
|
||||||
|
const override = heightOverrides[project.id.toString()];
|
||||||
|
if (isSelected(project.id) && override != null) return override;
|
||||||
|
if(!isSelected(project.id)) return 1;
|
||||||
|
return project.images?.length ? 2 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedProject = PROJECTS.find((p) => isSelected(p.id));
|
||||||
|
const selectedH = selectedProject ? h(selectedProject) : 1;
|
||||||
|
const nonSelected = PROJECTS.filter((p) => !isSelected(p.id));
|
||||||
|
|
||||||
|
function twoCol(project: (typeof PROJECTS)[number]) {
|
||||||
|
if (isSelected(project.id)) {
|
||||||
|
return { i: project.id.toString(), x: 0, y: 0, w: 2, h: h(project) };
|
||||||
|
}
|
||||||
|
const ni = nonSelected.indexOf(project);
|
||||||
|
return {
|
||||||
|
i: project.id.toString(),
|
||||||
|
x: ni % 2,
|
||||||
|
y: Math.floor(ni / 2) + selectedH,
|
||||||
|
w: 1,
|
||||||
|
h: 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function singleCol(project: (typeof PROJECTS)[number], colSpan: number) {
|
||||||
|
if (isSelected(project.id)) {
|
||||||
|
return { i: project.id.toString(), x: 0, y: 0, w: colSpan, h: h(project) };
|
||||||
|
}
|
||||||
|
const ni = nonSelected.indexOf(project);
|
||||||
|
return {
|
||||||
|
i: project.id.toString(),
|
||||||
|
x: 0,
|
||||||
|
y: ni + selectedH,
|
||||||
|
w: colSpan,
|
||||||
|
h: project.images?.length ? 4 : 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lg: PROJECTS.map((p) => twoCol(p)),
|
||||||
|
md: PROJECTS.map((p) => twoCol(p)),
|
||||||
|
sm: PROJECTS.map((p) => twoCol(p)),
|
||||||
|
xs: PROJECTS.map((p) => singleCol(p, 4)),
|
||||||
|
xxs: PROJECTS.map((p) => singleCol(p, 4)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRef = useRef(selectedProject);
|
||||||
|
selectedRef.current = selectedProject;
|
||||||
|
|
||||||
|
const pendingUpdate = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const updateHeight = useCallback(
|
||||||
|
(sourceId: number, contentRef: HTMLElement | null) => {
|
||||||
|
if (sourceId !== selectedRef.current) return;
|
||||||
|
|
||||||
|
if (pendingUpdate.current) clearTimeout(pendingUpdate.current);
|
||||||
|
pendingUpdate.current = setTimeout(() => {
|
||||||
|
const newH = contentRef
|
||||||
|
? Math.ceil(contentRef.getBoundingClientRect().height / rowHeight)
|
||||||
|
: 2;
|
||||||
|
|
||||||
|
const key = sourceId.toString();
|
||||||
|
if (heightCache.current[key] === newH) return;
|
||||||
|
heightCache.current[key] = newH;
|
||||||
|
setLayouts((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const bp in next) {
|
||||||
|
next[bp as DefaultBreakpoints] = next[bp as DefaultBreakpoints].map(
|
||||||
|
(layout) =>
|
||||||
|
layout.i === key ? { ...layout, h: newH } : layout
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, 10);
|
||||||
|
},
|
||||||
|
[rowHeight]
|
||||||
|
);
|
||||||
|
|
||||||
function setSelectedHandler(projectId: number) {
|
function setSelectedHandler(projectId: number) {
|
||||||
const id = Number(projectId);
|
const id = Number(projectId);
|
||||||
setSelectedProject(id);
|
setSelectedProject(id);
|
||||||
setLayouts(updateLayout(id, topCellHeight)); // pass both
|
heightCache.current = {};
|
||||||
|
setLayouts(buildLayouts(id, {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function updateLayout(activeId: number, cellHeight?: number) : Record<DefaultBreakpoints, Layout> {
|
|
||||||
const currentSelected = activeId ?? selectedProject;
|
|
||||||
const currentCellHeight = cellHeight ?? topCellHeight;
|
|
||||||
|
|
||||||
let isSelected = (projectId: number) => projectId === currentSelected;
|
|
||||||
return {
|
|
||||||
"sm": PROJECTS.map((project, i) => {
|
|
||||||
return {
|
|
||||||
i: project.id.toString(),
|
|
||||||
x: isSelected(project.id) ? 0 : (i % 2) + 1,
|
|
||||||
y: isSelected(project.id) ? 0 : Math.floor(i / 2) + (!isSelected(project.id) ? 1 : 0),
|
|
||||||
w: isSelected(project.id) ? 2 : 1,
|
|
||||||
h: project.images && project.images.length > 0 ? currentCellHeight : 1
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
"xs": PROJECTS.map((project, i) => {
|
|
||||||
return {
|
|
||||||
i: project.id.toString(),
|
|
||||||
x: 0,
|
|
||||||
y: i,
|
|
||||||
w: 2,
|
|
||||||
h: project.images && project.images.length > 0 ? currentCellHeight : 1
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
"xxs": PROJECTS.map((project, i) => {
|
|
||||||
return {
|
|
||||||
i: project.id.toString(),
|
|
||||||
x: 0,
|
|
||||||
y: i,
|
|
||||||
w: 2,
|
|
||||||
h: project.images && project.images.length > 0 ? currentCellHeight : 1
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
"md": PROJECTS.map((project, i) => {
|
|
||||||
return {
|
|
||||||
i: project.id.toString(),
|
|
||||||
x: isSelected(project.id) ? 0 : (i % 2) + 1,
|
|
||||||
y: isSelected(project.id) ? 0 : Math.floor(i / 2) + (!isSelected(project.id) ? 1 : 0),
|
|
||||||
w: isSelected(project.id) ? 2 : 1,
|
|
||||||
h: project.images && project.images.length > 0 ? currentCellHeight : 1
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
"lg": PROJECTS.map((project, i) => {
|
|
||||||
return {
|
|
||||||
i: project.id.toString(),
|
|
||||||
x: isSelected(project.id) ? 0 : (i % 2) + 1,
|
|
||||||
y: isSelected(project.id) ? 0 : Math.floor(i / 2) + (!isSelected(project.id) ? 1 : 0),
|
|
||||||
w: isSelected(project.id) ? 2 : 1,
|
|
||||||
h: project.images && project.images.length > 0 ? currentCellHeight : 1
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "48px 0"}}>
|
<div style={{ padding: "48px 0", minWidth: 400 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -118,32 +150,32 @@ export default function Projects() {
|
|||||||
{"Things I've built"}
|
{"Things I've built"}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div ref={containerRef}>
|
<div ref={containerRef}>
|
||||||
{mounted && (<Responsive
|
{mounted && (
|
||||||
layouts={layouts}
|
<Responsive
|
||||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
|
||||||
cols={{ lg: 2, md: 2, sm: 2, xs: 2, xxs: 2 }}
|
|
||||||
width={width}
|
|
||||||
compactor={horizontalCompactor}
|
|
||||||
rowHeight={rowHeight}
|
|
||||||
dragConfig={{enabled:false}}
|
|
||||||
>
|
|
||||||
{PROJECTS.map((project, i) => (
|
|
||||||
<div key={project.id} style={{pointerEvents: "none"}}>
|
|
||||||
<ProjectCard
|
|
||||||
project={project}
|
|
||||||
visible={visible.has(i)}
|
|
||||||
selected={selectedProject === project.id}
|
|
||||||
setSelected={setSelectedHandler}
|
|
||||||
setExpandedHeight={setExpandedHeight}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
))}
|
|
||||||
</Responsive>)}
|
|
||||||
|
|
||||||
|
layouts={layouts}
|
||||||
|
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||||
|
cols={{ lg: 2, md: 2, sm: 2, xs: 2, xxs: 2 }}
|
||||||
|
width={width}
|
||||||
|
compactor={horizontalCompactor}
|
||||||
|
rowHeight={rowHeight}
|
||||||
|
dragConfig={{ enabled: false }}
|
||||||
|
>
|
||||||
|
{PROJECTS.map((project, i) => (
|
||||||
|
<div key={project.id} style={{ pointerEvents: "none" }}>
|
||||||
|
<ProjectCard
|
||||||
|
project={project}
|
||||||
|
visible={visible.has(i)}
|
||||||
|
selected={selectedProject === project.id}
|
||||||
|
setSelected={setSelectedHandler}
|
||||||
|
updateHeight={updateHeight}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Responsive>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,6 @@ type Props = {
|
|||||||
images: string[];
|
images: string[];
|
||||||
videos?: string[];
|
videos?: string[];
|
||||||
title: string;
|
title: string;
|
||||||
expansionHandler: (height: number) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 0 = inline default, 1 = inline enlarged, 2 = fullscreen overlay
|
// 0 = inline default, 1 = inline enlarged, 2 = fullscreen overlay
|
||||||
@@ -15,8 +14,7 @@ type EnlargeLevel = 0 | 1 | 2;
|
|||||||
export default function SimpleGallery({
|
export default function SimpleGallery({
|
||||||
images,
|
images,
|
||||||
videos = [],
|
videos = [],
|
||||||
title,
|
title
|
||||||
expansionHandler
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
@@ -31,28 +29,10 @@ export default function SimpleGallery({
|
|||||||
|
|
||||||
if (totalMedia === 0) return null;
|
if (totalMedia === 0) return null;
|
||||||
|
|
||||||
function scheduleHeightUpdate(enlarged: boolean) {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
if (!enlarged) {
|
|
||||||
expansionHandler(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el =
|
|
||||||
document.getElementById("gallery-main-image") ??
|
|
||||||
document.getElementById("gallery-main-video");
|
|
||||||
if (el){
|
|
||||||
let h = el.getBoundingClientRect().height;
|
|
||||||
let adjAmount = h + 150;
|
|
||||||
expansionHandler(adjAmount);
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEnlarge() {
|
function handleEnlarge() {
|
||||||
if (enlargeLevel === 0) {
|
if (enlargeLevel === 0) {
|
||||||
setEnlargeLevel(1);
|
setEnlargeLevel(1);
|
||||||
scheduleHeightUpdate(true);
|
|
||||||
} else if (enlargeLevel === 1) {
|
} else if (enlargeLevel === 1) {
|
||||||
setEnlargeLevel(2);
|
setEnlargeLevel(2);
|
||||||
}
|
}
|
||||||
@@ -63,7 +43,6 @@ export default function SimpleGallery({
|
|||||||
setEnlargeLevel(1);
|
setEnlargeLevel(1);
|
||||||
} else if (enlargeLevel === 1) {
|
} else if (enlargeLevel === 1) {
|
||||||
setEnlargeLevel(0);
|
setEnlargeLevel(0);
|
||||||
scheduleHeightUpdate(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,11 +52,6 @@ export default function SimpleGallery({
|
|||||||
|
|
||||||
function handleThumbnailClick(index: number) {
|
function handleThumbnailClick(index: number) {
|
||||||
setActive(index);
|
setActive(index);
|
||||||
if (enlargeLevel === 1) {
|
|
||||||
scheduleHeightUpdate(true);
|
|
||||||
} else if (enlargeLevel === 2) {
|
|
||||||
// stay fullscreen with new media
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandButton() {
|
function expandButton() {
|
||||||
@@ -398,7 +372,7 @@ export default function SimpleGallery({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
<div style={{ position: "relative", display: "inline-block", width: "100%" }}>
|
<div style={{ position: "relative", display: "inline-block", width: "100%" }}>
|
||||||
{inlineMedia()}
|
{inlineMedia()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,4 +83,12 @@ export const PROJECTS: Project[] = [{
|
|||||||
tags: ["C#", ".NET", "WPF", "Netcode", "Security"],
|
tags: ["C#", ".NET", "WPF", "Netcode", "Security"],
|
||||||
link: "https://github.com/FerrenF/CleanSpace",
|
link: "https://github.com/FerrenF/CleanSpace",
|
||||||
year: "2025",
|
year: "2025",
|
||||||
}];
|
}, {
|
||||||
|
id: 3,
|
||||||
|
slug: "personal-portfolio",
|
||||||
|
title: "Personal Portfolio Website",
|
||||||
|
description: "This very website! Built with Next.JS, React, and TypeScript. Featuring a custom CMS and a lot of custom-built components and hooks.",
|
||||||
|
tags: ["Next.JS", "React", "TypeScript", "Vercel"],
|
||||||
|
link: "https://git.hwilliams.dev/hwilliams/hwilliams-dev",
|
||||||
|
year: "2026",
|
||||||
|
}];
|
||||||
|
|||||||
Reference in New Issue
Block a user