mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-04 22:32:55 +00:00
refactor(frontend): improve type safety and migrate to ESLint 9
Migrate the frontend to ESLint 9, add `eslint.config.mjs`, and replace various `any` types with more specific or safer alternatives across the codebase. - Update `package.json` with new ESLint and TypeScript-ESLint dependencies - Replace `any` with `Record<string, unknown>` or specific types in `TemplatePicker`, `Editor`, and `FileBrowser` - Improve error handling in `TeamSettings` and `Templates` using type guards - Add explicit type imports for Excalidraw elements in `Editor.tsx` - Refactor `Modal.tsx` for cleaner conditional logic - Add `check` script to `package.json` for type checking
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import pluginReact from 'eslint-plugin-react';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist/', 'node_modules/', 'dist-test/', '*.config.*', 'eslint.config.mjs', 'e2e/', 'playwright/'] },
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
plugins: { react: pluginReact },
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: { project: './tsconfig.json' },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||||
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
Generated
+3096
-2
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
"check": "tsc --noEmit",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
@@ -37,9 +38,12 @@
|
|||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"eslint-plugin-react": "^7.37.0",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"sass": "^1.81.0",
|
"sass": "^1.81.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
|
"typescript-eslint": "^8.0.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Search, Bell, Plus, FileText, Loader2, Sun, Moon } from 'lucide-react';
|
|||||||
import { Button } from '@/components';
|
import { Button } from '@/components';
|
||||||
import { useThemeStore } from '@/stores';
|
import { useThemeStore } from '@/stores';
|
||||||
import { api } from '@/services';
|
import { api } from '@/services';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
import type { Drawing } from '@/types';
|
import type { Drawing } from '@/types';
|
||||||
import styles from './Layout.module.scss';
|
import styles from './Layout.module.scss';
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
onCancel?.() ?? onClose?.();
|
if (onCancel) {
|
||||||
|
onCancel();
|
||||||
|
} else {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ interface TemplatePickerProps {
|
|||||||
onSelect: (template: PickedTemplate) => void;
|
onSelect: (template: PickedTemplate) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RawElement = Record<string, unknown>;
|
||||||
|
|
||||||
interface TemplateOption {
|
interface TemplateOption {
|
||||||
id: PickedTemplate;
|
id: PickedTemplate;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
elements: any[];
|
elements: RawElement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeHandDrawnRect(x: number, y: number, w: number, h: number, text?: string) {
|
function makeHandDrawnRect(x: number, y: number, w: number, h: number, text?: string) {
|
||||||
@@ -84,15 +86,16 @@ function makeText(x: number, y: number, text: string, fontSize = 20) {
|
|||||||
|
|
||||||
function makeCheckbox(x: number, y: number, checked = false) {
|
function makeCheckbox(x: number, y: number, checked = false) {
|
||||||
const box = makeHandDrawnRect(x, y, 20, 20);
|
const box = makeHandDrawnRect(x, y, 20, 20);
|
||||||
(box as any).backgroundColor = checked ? '#a5eba8' : 'transparent';
|
return Object.assign(box, {
|
||||||
(box as any).customData = {
|
backgroundColor: checked ? '#a5eba8' : 'transparent',
|
||||||
templateRole: 'checkbox',
|
customData: {
|
||||||
checked,
|
templateRole: 'checkbox',
|
||||||
};
|
checked,
|
||||||
return box;
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BUILTIN_TEMPLATES: Record<PickedTemplate, any[]> = {
|
export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||||
blank: [],
|
blank: [],
|
||||||
todo: [
|
todo: [
|
||||||
makeHandDrawnRect(50, 50, 500, 50),
|
makeHandDrawnRect(50, 50, 500, 50),
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import './i18n';
|
|||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import './styles/global.scss';
|
import './styles/global.scss';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
const rootElement = document.getElementById('root');
|
||||||
|
if (!rootElement) throw new Error('Root element not found');
|
||||||
|
ReactDOM.createRoot(rootElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -12,45 +12,40 @@ import styles from './Editor.module.scss';
|
|||||||
// Dynamic import for Excalidraw to avoid SSR issues
|
// Dynamic import for Excalidraw to avoid SSR issues
|
||||||
const Excalidraw = React.lazy(() => import('@excalidraw/excalidraw').then(mod => ({ default: mod.Excalidraw })));
|
const Excalidraw = React.lazy(() => import('@excalidraw/excalidraw').then(mod => ({ default: mod.Excalidraw })));
|
||||||
|
|
||||||
interface ExcalidrawElement {
|
import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types';
|
||||||
id: string;
|
import type { ExcalidrawImperativeAPI, ExcalidrawInitialDataState } from '@excalidraw/excalidraw/types/types';
|
||||||
type: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExcalidrawState {
|
type LooseElement = Record<string, unknown>;
|
||||||
|
|
||||||
|
interface EditorState {
|
||||||
elements: ExcalidrawElement[];
|
elements: ExcalidrawElement[];
|
||||||
appState: Record<string, unknown>;
|
appState: Record<string, unknown>;
|
||||||
files: Record<string, { dataURL: string; mimeType: string }>;
|
files: Record<string, { dataURL: string; mimeType: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareElementsForImport(sourceElements: any[], offsetX: number, offsetY: number): any[] {
|
function prepareElementsForImport(sourceElements: LooseElement[], offsetX: number, offsetY: number): LooseElement[] {
|
||||||
if (!sourceElements || !sourceElements.length) return [];
|
if (!sourceElements || !sourceElements.length) return [];
|
||||||
const idMap = new Map<string, string>();
|
const idMap = new Map<string, string>();
|
||||||
sourceElements.forEach((el: any) => {
|
sourceElements.forEach((el) => {
|
||||||
idMap.set(el.id, `${el.type}-${Math.random().toString(36).slice(2, 9)}`);
|
idMap.set(el.id as string, `${el.type}-${Math.random().toString(36).slice(2, 9)}`);
|
||||||
});
|
});
|
||||||
return sourceElements.map((el: any) => {
|
return sourceElements.map((el) => {
|
||||||
const newEl = { ...el };
|
const newEl: LooseElement = { ...el };
|
||||||
newEl.id = idMap.get(el.id) || el.id;
|
newEl.id = idMap.get(el.id as string) || el.id;
|
||||||
newEl.x = (el.x || 0) + offsetX;
|
newEl.x = ((el.x as number) || 0) + offsetX;
|
||||||
newEl.y = (el.y || 0) + offsetY;
|
newEl.y = ((el.y as number) || 0) + offsetY;
|
||||||
newEl.version = (el.version || 1) + 1;
|
newEl.version = ((el.version as number) || 1) + 1;
|
||||||
newEl.versionNonce = Math.floor(Math.random() * 1000000);
|
newEl.versionNonce = Math.floor(Math.random() * 1000000);
|
||||||
newEl.updated = Date.now();
|
newEl.updated = Date.now();
|
||||||
newEl.seed = Math.floor(Math.random() * 100000);
|
newEl.seed = Math.floor(Math.random() * 100000);
|
||||||
if (newEl.boundElements) {
|
if (newEl.boundElements) {
|
||||||
newEl.boundElements = newEl.boundElements.map((be: any) => ({
|
newEl.boundElements = (newEl.boundElements as LooseElement[]).map((be) => ({
|
||||||
...be,
|
...be,
|
||||||
id: idMap.get(be.id) || be.id,
|
id: idMap.get(be.id as string) || be.id,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (newEl.containerId && idMap.has(newEl.containerId)) {
|
if (newEl.containerId && idMap.has(newEl.containerId as string)) {
|
||||||
newEl.containerId = idMap.get(newEl.containerId);
|
newEl.containerId = idMap.get(newEl.containerId as string);
|
||||||
}
|
}
|
||||||
return newEl;
|
return newEl;
|
||||||
});
|
});
|
||||||
@@ -71,7 +66,7 @@ export const Editor: React.FC = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [drawing, setDrawing] = useState<Drawing | null>(null);
|
const [drawing, setDrawing] = useState<Drawing | null>(null);
|
||||||
const [revisions, setRevisions] = useState<DrawingRevision[]>([]);
|
const [revisions, setRevisions] = useState<DrawingRevision[]>([]);
|
||||||
const [initialData, setInitialData] = useState<any>(null);
|
const [initialData, setInitialData] = useState<ExcalidrawInitialDataState | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved');
|
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved');
|
||||||
@@ -81,11 +76,11 @@ export const Editor: React.FC = () => {
|
|||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [selectedRevision, setSelectedRevision] = useState<string | null>(null);
|
const [selectedRevision, setSelectedRevision] = useState<string | null>(null);
|
||||||
const { theme: appTheme } = useThemeStore();
|
const { theme: appTheme } = useThemeStore();
|
||||||
const currentStateRef = useRef<ExcalidrawState | null>(null);
|
const currentStateRef = useRef<EditorState | null>(null);
|
||||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastSavedDataRef = useRef<string>('');
|
const lastSavedDataRef = useRef<string>('');
|
||||||
const lastToggledCheckboxRef = useRef<string | null>(null);
|
const lastToggledCheckboxRef = useRef<string | null>(null);
|
||||||
const [excalidrawAPI, setExcalidrawAPI] = useState<any>(null);
|
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI | null>(null);
|
||||||
|
|
||||||
const [showTemplates, setShowTemplates] = useState(false);
|
const [showTemplates, setShowTemplates] = useState(false);
|
||||||
|
|
||||||
@@ -144,13 +139,13 @@ export const Editor: React.FC = () => {
|
|||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
// Handle changes from Excalidraw
|
// Handle changes from Excalidraw
|
||||||
const handleExcalidrawChange = useCallback((elements: readonly unknown[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
|
const handleExcalidrawChange = useCallback((elements: readonly ExcalidrawElement[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
|
||||||
const selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {});
|
const selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {});
|
||||||
const selectedCheckbox = selectedIds.length === 1
|
const selectedCheckbox = selectedIds.length === 1
|
||||||
? (elements as any[]).find((el) => (
|
? elements.find((el) => (
|
||||||
el.id === selectedIds[0] &&
|
el.id === selectedIds[0] &&
|
||||||
!el.isDeleted &&
|
!el.isDeleted &&
|
||||||
el.customData?.templateRole === 'checkbox'
|
(el.customData as Record<string, unknown> | undefined)?.templateRole === 'checkbox'
|
||||||
))
|
))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -158,23 +153,23 @@ export const Editor: React.FC = () => {
|
|||||||
lastToggledCheckboxRef.current = null;
|
lastToggledCheckboxRef.current = null;
|
||||||
} else if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedCheckbox.id) {
|
} else if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedCheckbox.id) {
|
||||||
lastToggledCheckboxRef.current = selectedCheckbox.id;
|
lastToggledCheckboxRef.current = selectedCheckbox.id;
|
||||||
const nextChecked = !selectedCheckbox.customData?.checked;
|
const nextChecked = !((selectedCheckbox.customData as Record<string, unknown> | undefined)?.checked as boolean);
|
||||||
const nextElements = (elements as any[]).map((el) => (
|
const nextElements = elements.map((el) => (
|
||||||
el.id === selectedCheckbox.id
|
el.id === selectedCheckbox.id
|
||||||
? {
|
? {
|
||||||
...el,
|
...el,
|
||||||
backgroundColor: nextChecked ? '#a5eba8' : 'transparent',
|
backgroundColor: nextChecked ? '#a5eba8' : 'transparent',
|
||||||
customData: {
|
customData: {
|
||||||
...(el.customData || {}),
|
...((el.customData as Record<string, unknown> | undefined) || {}),
|
||||||
checked: nextChecked,
|
checked: nextChecked,
|
||||||
},
|
},
|
||||||
version: (el.version || 1) + 1,
|
version: el.version + 1,
|
||||||
versionNonce: Math.floor(Math.random() * 1000000),
|
versionNonce: Math.floor(Math.random() * 1000000),
|
||||||
updated: Date.now(),
|
updated: Date.now(),
|
||||||
}
|
}
|
||||||
: el
|
: el
|
||||||
));
|
));
|
||||||
excalidrawAPI.updateScene({ elements: nextElements });
|
excalidrawAPI.updateScene({ elements: nextElements as ExcalidrawElement[] });
|
||||||
currentStateRef.current = {
|
currentStateRef.current = {
|
||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: appStateWithoutGrid(appState),
|
appState: appStateWithoutGrid(appState),
|
||||||
@@ -185,7 +180,7 @@ export const Editor: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentStateRef.current = {
|
currentStateRef.current = {
|
||||||
elements: elements as ExcalidrawElement[],
|
elements: elements as unknown as ExcalidrawElement[],
|
||||||
appState: appStateWithoutGrid(appState),
|
appState: appStateWithoutGrid(appState),
|
||||||
files,
|
files,
|
||||||
};
|
};
|
||||||
@@ -286,14 +281,14 @@ export const Editor: React.FC = () => {
|
|||||||
if (!templateElements || !excalidrawAPI) return;
|
if (!templateElements || !excalidrawAPI) return;
|
||||||
const currentElements = excalidrawAPI.getSceneElements?.() || [];
|
const currentElements = excalidrawAPI.getSceneElements?.() || [];
|
||||||
let offsetX = 100;
|
let offsetX = 100;
|
||||||
let offsetY = 100;
|
const offsetY = 100;
|
||||||
if (currentElements.length > 0) {
|
if (currentElements.length > 0) {
|
||||||
const maxX = Math.max(...currentElements.map((el: any) => (el.x || 0) + (el.width || 0)));
|
const maxX = Math.max(...currentElements.map((el) => (el.x + el.width)));
|
||||||
offsetX = maxX + 100;
|
offsetX = maxX + 100;
|
||||||
}
|
}
|
||||||
const newElements = prepareElementsForImport(templateElements, offsetX, offsetY);
|
const newElements = prepareElementsForImport(templateElements, offsetX, offsetY);
|
||||||
const mergedElements = [...currentElements, ...newElements];
|
const mergedElements = [...currentElements, ...newElements];
|
||||||
excalidrawAPI.updateScene({ elements: mergedElements });
|
excalidrawAPI.updateScene({ elements: mergedElements as ExcalidrawElement[] });
|
||||||
setShowTemplates(false);
|
setShowTemplates(false);
|
||||||
setSaveStatus('unsaved');
|
setSaveStatus('unsaved');
|
||||||
};
|
};
|
||||||
@@ -407,7 +402,7 @@ export const Editor: React.FC = () => {
|
|||||||
{initialData && (
|
{initialData && (
|
||||||
<React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}>
|
<React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}>
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
excalidrawAPI={(api: any) => setExcalidrawAPI(api)}
|
excalidrawAPI={(api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api)}
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
onChange={handleExcalidrawChange}
|
onChange={handleExcalidrawChange}
|
||||||
theme={appTheme === 'dark' ? 'dark' : 'light'}
|
theme={appTheme === 'dark' ? 'dark' : 'light'}
|
||||||
|
|||||||
@@ -273,7 +273,12 @@ export const FileBrowser: React.FC = () => {
|
|||||||
<select
|
<select
|
||||||
className={styles.filterSelect}
|
className={styles.filterSelect}
|
||||||
value={visibilityFilter}
|
value={visibilityFilter}
|
||||||
onChange={(e) => setVisibilityFilter(e.target.value as any)}
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (v === 'all' || v === 'private' || v === 'team' || v === 'public-link') {
|
||||||
|
setVisibilityFilter(v);
|
||||||
|
}
|
||||||
|
}}
|
||||||
aria-label="Filter by visibility"
|
aria-label="Filter by visibility"
|
||||||
title="Filter by visibility"
|
title="Filter by visibility"
|
||||||
>
|
>
|
||||||
@@ -287,8 +292,12 @@ export const FileBrowser: React.FC = () => {
|
|||||||
value={`${sortBy}-${sortOrder}`}
|
value={`${sortBy}-${sortOrder}`}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const [sb, so] = e.target.value.split('-');
|
const [sb, so] = e.target.value.split('-');
|
||||||
setSortBy(sb as any);
|
if (sb === 'name' || sb === 'updated' || sb === 'created') {
|
||||||
setSortOrder(so as any);
|
setSortBy(sb);
|
||||||
|
}
|
||||||
|
if (so === 'asc' || so === 'desc') {
|
||||||
|
setSortOrder(so);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
aria-label="Sort drawings"
|
aria-label="Sort drawings"
|
||||||
title="Sort drawings"
|
title="Sort drawings"
|
||||||
|
|||||||
@@ -70,8 +70,9 @@ export const TeamSettings: React.FC = () => {
|
|||||||
setNewEmail('');
|
setNewEmail('');
|
||||||
setNewPassword('');
|
setNewPassword('');
|
||||||
setNewRole('editor');
|
setNewRole('editor');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err?.message || 'Failed to create user');
|
const message = err instanceof Error ? err.message : 'Failed to create user';
|
||||||
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const Templates: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const t = await api.templates.create({ name: name.trim(), type: 'empty', scope: 'personal' });
|
const t = await api.templates.create({ name: name.trim(), type: 'empty', scope: 'personal' });
|
||||||
setTemplates([t, ...templates]); setShowModal(false); setName('');
|
setTemplates([t, ...templates]); setShowModal(false); setName('');
|
||||||
} catch (err) { setError('Create failed'); }
|
} catch { setError('Create failed'); }
|
||||||
finally { setCreating(false); }
|
finally { setCreating(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user