This commit is contained in:
@@ -10,6 +10,8 @@ import { SystemTab } from './components/SystemTab';
|
||||
import { MyAccountTab } from './components/AccountsTab';
|
||||
import { StudentAffairsTab } from './components/StudentAffairsTab';
|
||||
import { ApplicationsTab } from './components/ApplicationsTab';
|
||||
import { RescideTab } from './components/RescideTab';
|
||||
import { RescideAnnouncePopup } from './components/RescideAnnouncePopup';
|
||||
import { ChatWidget } from './components/ChatWidget';
|
||||
import { StaffChatSocket } from './hooks/useStaffChatSocket';
|
||||
import { ClassWorkspace } from './components/ClassWorkspace';
|
||||
@@ -89,6 +91,14 @@ const IconApplications = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconRescide = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
<line x1="14" y1="4" x2="10" y2="20" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMyClass = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M12 3 2 9l10 6 10-6-10-6Z" />
|
||||
@@ -329,6 +339,16 @@ function App() {
|
||||
<span className="nav-label">Ứng dụng</span>
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
className={navBtn(route.tab === 'rescide' && !inWorkspace)}
|
||||
onClick={() => setActiveTab('rescide')}
|
||||
>
|
||||
<span className="nav-icon"><IconRescide /></span>
|
||||
<span className="nav-label">RESCIDE IDE</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li className="nav-section" aria-hidden="true">
|
||||
<span className="nav-header">Hệ thống</span>
|
||||
@@ -429,10 +449,12 @@ function App() {
|
||||
{route.tab === 'profile' && <MyAccountTab />}
|
||||
{route.tab === 'student-affairs' && <StudentAffairsTab />}
|
||||
{route.tab === 'applications' && <ApplicationsTab />}
|
||||
{route.tab === 'rescide' && <RescideTab />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<RescideAnnouncePopup disabled={inWorkspace} />
|
||||
<StaffChatSocket />
|
||||
<ChatWidget />
|
||||
</>
|
||||
|
||||
118
management/src/components/RescideAnnouncePopup.tsx
Normal file
118
management/src/components/RescideAnnouncePopup.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import {
|
||||
isRescidePopupHidden,
|
||||
RESCIDE_DEFAULT_DOWNLOADS,
|
||||
RESCIDE_FEATURES,
|
||||
setRescidePopupHidden,
|
||||
} from '../rescide';
|
||||
import { navigate } from '../navigation';
|
||||
|
||||
type Props = {
|
||||
/** Khi true, không hiện popup (ví dụ đang ở workspace lớp/thi). */
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const RescideAnnouncePopup: React.FC<Props> = ({ disabled }) => {
|
||||
const { staff } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled || !staff?.email) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setOpen(!isRescidePopupHidden(staff.email));
|
||||
}, [staff?.email, disabled]);
|
||||
|
||||
if (!open || !staff?.email) return null;
|
||||
|
||||
const dismissOnce = () => setOpen(false);
|
||||
|
||||
const dismissForever = () => {
|
||||
setRescidePopupHidden(staff.email, true);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const goTab = () => {
|
||||
setOpen(false);
|
||||
navigate('rescide');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" style={{ zIndex: 1200 }} onClick={dismissOnce}>
|
||||
<div
|
||||
className="modal-container"
|
||||
style={{ maxWidth: 520, width: '92%' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header" style={{ borderBottom: '1px solid var(--border-light)' }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>
|
||||
Thông báo: RESCIDE IDE
|
||||
</h2>
|
||||
<button type="button" className="modal-close-btn" aria-label="Đóng" onClick={dismissOnce}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ padding: '1.25rem 1.5rem' }}>
|
||||
<p style={{ fontSize: '0.95rem', color: 'var(--text-secondary)', lineHeight: 1.6, marginBottom: '1rem' }}>
|
||||
<strong style={{ color: 'var(--text-primary)' }}>RESCIDE</strong> — IDE thi thực hành đang mở test thử để
|
||||
đánh giá và cải thiện chức năng. Mục đích: môi trường IDE chuyên cho kỳ thi thực hành, chống gian lận cùng
|
||||
RAIA DESKTOP.
|
||||
</p>
|
||||
<ul
|
||||
style={{
|
||||
margin: '0 0 1.25rem',
|
||||
paddingLeft: '1.15rem',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.65,
|
||||
}}
|
||||
>
|
||||
{RESCIDE_FEATURES.map((f) => (
|
||||
<li key={f} style={{ marginBottom: '0.35rem' }}>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
{RESCIDE_DEFAULT_DOWNLOADS.map((d) => (
|
||||
<a
|
||||
key={d.downloadUrl}
|
||||
href={d.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-secondary"
|
||||
style={{ justifyContent: 'space-between', textDecoration: 'none', width: '100%' }}
|
||||
>
|
||||
<span>{d.name}</span>
|
||||
<span style={{ fontWeight: 700, color: 'var(--accent)' }}>Tải</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="modal-footer"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
justifyContent: 'flex-end',
|
||||
borderTop: '1px solid var(--border-light)',
|
||||
padding: '1rem 1.5rem',
|
||||
}}
|
||||
>
|
||||
<button type="button" className="btn btn-secondary" onClick={dismissForever}>
|
||||
Ẩn với tài khoản này
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={goTab}>
|
||||
Xem chi tiết
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={dismissOnce}>
|
||||
Đã hiểu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
182
management/src/components/RescideTab.tsx
Normal file
182
management/src/components/RescideTab.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api, type AppDownloadItem } from '../api';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import {
|
||||
isRescideManager,
|
||||
isRescidePopupHidden,
|
||||
RESCIDE_DEFAULT_DOWNLOADS,
|
||||
RESCIDE_FEATURES,
|
||||
setRescidePopupHidden,
|
||||
type RescideDownload,
|
||||
} from '../rescide';
|
||||
import { navigate } from '../navigation';
|
||||
|
||||
function mergeDownloads(fromApi: AppDownloadItem[]): RescideDownload[] {
|
||||
const rescide = fromApi.filter((a) => a.name.toUpperCase().includes('RESCIDE'));
|
||||
if (rescide.length > 0) {
|
||||
return rescide.map((a) => ({
|
||||
name: a.name,
|
||||
description: a.description || '',
|
||||
downloadUrl: a.downloadUrl,
|
||||
platform: a.platform || '',
|
||||
}));
|
||||
}
|
||||
return RESCIDE_DEFAULT_DOWNLOADS;
|
||||
}
|
||||
|
||||
export const RescideTab: React.FC = () => {
|
||||
const { staff } = useAuth();
|
||||
const canManage = isRescideManager(staff?.email);
|
||||
const [downloads, setDownloads] = useState<RescideDownload[]>(RESCIDE_DEFAULT_DOWNLOADS);
|
||||
const [popupHidden, setPopupHidden] = useState(() => isRescidePopupHidden(staff?.email));
|
||||
|
||||
useEffect(() => {
|
||||
setPopupHidden(isRescidePopupHidden(staff?.email));
|
||||
}, [staff?.email]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.listAppDownloads()
|
||||
.then((res) => {
|
||||
if (!cancelled) setDownloads(mergeDownloads(res.data || []));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDownloads(RESCIDE_DEFAULT_DOWNLOADS);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const togglePopupHide = () => {
|
||||
if (!staff?.email) return;
|
||||
const next = !popupHidden;
|
||||
setRescidePopupHidden(staff.email, next);
|
||||
setPopupHidden(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-page">
|
||||
<div className="tab-page-toolbar">
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1>RESCIDE — IDE thi thực hành</h1>
|
||||
<p>
|
||||
Mở test thử để đánh giá và cải thiện chức năng. IDE chuyên dụng cho kỳ thi thực hành, chống gian lận
|
||||
cùng RAIA DESKTOP.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tab-page-body tab-page-scroll">
|
||||
<div
|
||||
className="content-card"
|
||||
style={{
|
||||
marginBottom: '1.25rem',
|
||||
padding: '1.25rem 1.5rem',
|
||||
borderColor: 'rgba(187, 33, 38, 0.25)',
|
||||
background: 'linear-gradient(135deg, #fff 0%, var(--accent-light) 100%)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--accent)', letterSpacing: '0.04em', textTransform: 'uppercase', marginBottom: '0.35rem' }}>
|
||||
Thông báo thử nghiệm
|
||||
</div>
|
||||
<p style={{ fontSize: '0.95rem', color: 'var(--text-secondary)', lineHeight: 1.55, margin: 0, maxWidth: 640 }}>
|
||||
Vui lòng tải RESCIDE, dùng thử trong môi trường thi thực hành và góp ý để đội ngũ hoàn thiện sản phẩm.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={togglePopupHide}
|
||||
title={popupHidden ? 'Hiện popup RESCIDE mỗi khi vào web' : 'Ẩn popup RESCIDE với tài khoản này'}
|
||||
>
|
||||
{popupHidden ? 'Hiện lại popup khi vào web' : 'Ẩn popup với tài khoản này'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content-card" style={{ marginBottom: '1.25rem', padding: '1.5rem' }}>
|
||||
<h2 style={{ fontSize: '1.05rem', fontWeight: 700, marginBottom: '0.75rem' }}>Chức năng tiêu biểu</h2>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.2rem', color: 'var(--text-secondary)', lineHeight: 1.7, fontSize: '0.95rem' }}>
|
||||
{RESCIDE_FEATURES.map((f) => (
|
||||
<li key={f} style={{ marginBottom: '0.4rem' }}>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="content-card" style={{ padding: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
<h2 style={{ fontSize: '1.05rem', fontWeight: 700, margin: 0 }}>Tải RESCIDE</h2>
|
||||
{canManage && (
|
||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('applications')}>
|
||||
Quản lý link tải (Ứng dụng)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
|
||||
Chọn bản cài đặt phù hợp hệ điều hành. Link tải được đồng bộ từ khu vực Ứng dụng
|
||||
{canManage ? ' — bạn có thể chỉnh sửa tại tab Ứng dụng.' : '.'}
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '1rem' }}>
|
||||
{downloads.map((d) => (
|
||||
<a
|
||||
key={`${d.name}-${d.downloadUrl}`}
|
||||
href={d.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="content-card"
|
||||
style={{
|
||||
padding: '1.25rem',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--border-color)',
|
||||
transition: 'var(--transition)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(187, 33, 38, 0.35)';
|
||||
e.currentTarget.style.boxShadow = 'var(--shadow-md)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-color)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignSelf: 'flex-start',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
color: 'var(--accent)',
|
||||
background: 'var(--accent-light)',
|
||||
padding: '0.2rem 0.55rem',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{d.platform || 'Download'}
|
||||
</span>
|
||||
<strong style={{ fontSize: '0.95rem', color: 'var(--text-primary)' }}>{d.name}</strong>
|
||||
{d.description && (
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', lineHeight: 1.45 }}>{d.description}</span>
|
||||
)}
|
||||
<span style={{ marginTop: 'auto', paddingTop: '0.75rem', fontSize: '0.85rem', fontWeight: 600, color: 'var(--accent)' }}>
|
||||
Tải về →
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
export type SystemSection = 'organization' | 'network' | 'templates' | 'seating';
|
||||
|
||||
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs' | 'applications';
|
||||
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs' | 'applications' | 'rescide';
|
||||
|
||||
export interface NavEntry {
|
||||
id: string;
|
||||
@@ -30,6 +30,7 @@ export const TAB_LABELS: Record<TabId, string> = {
|
||||
profile: 'Tài khoản của tôi',
|
||||
'student-affairs': 'Công Tác Sinh Viên',
|
||||
applications: 'Ứng Dụng',
|
||||
rescide: 'RESCIDE IDE',
|
||||
};
|
||||
|
||||
const HISTORY_KEY = 'sc_nav_history';
|
||||
@@ -45,7 +46,8 @@ function isTabId(value: string | null): value is TabId {
|
||||
value === 'system' ||
|
||||
value === 'profile' ||
|
||||
value === 'student-affairs' ||
|
||||
value === 'applications'
|
||||
value === 'applications' ||
|
||||
value === 'rescide'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
67
management/src/rescide.ts
Normal file
67
management/src/rescide.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/** RESCIDE IDE — thông báo & link tải (quản lý CRUD tại tab Ứng dụng, tài khoản super-admin). */
|
||||
|
||||
export const RESCIDE_SUPER_ADMIN_EMAIL = 'phuocntb@rikkeiacademy.com';
|
||||
|
||||
export const RESCIDE_POPUP_STORAGE_PREFIX = 'sc_rescide_popup_hidden:';
|
||||
|
||||
export function rescidePopupStorageKey(email: string) {
|
||||
return `${RESCIDE_POPUP_STORAGE_PREFIX}${email.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function isRescidePopupHidden(email: string | undefined | null): boolean {
|
||||
if (!email) return true;
|
||||
try {
|
||||
return localStorage.getItem(rescidePopupStorageKey(email)) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setRescidePopupHidden(email: string, hidden: boolean) {
|
||||
try {
|
||||
const key = rescidePopupStorageKey(email);
|
||||
if (hidden) localStorage.setItem(key, '1');
|
||||
else localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function isRescideManager(email: string | undefined | null): boolean {
|
||||
return email?.trim().toLowerCase() === RESCIDE_SUPER_ADMIN_EMAIL;
|
||||
}
|
||||
|
||||
export type RescideDownload = {
|
||||
name: string;
|
||||
description: string;
|
||||
downloadUrl: string;
|
||||
platform: string;
|
||||
};
|
||||
|
||||
export const RESCIDE_DEFAULT_DOWNLOADS: RescideDownload[] = [
|
||||
{
|
||||
name: 'RESCIDE (macOS Intel)',
|
||||
description: 'IDE thi thực hành — macOS chip Intel',
|
||||
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE_intel-installer.dmg',
|
||||
platform: 'macOS',
|
||||
},
|
||||
{
|
||||
name: 'RESCIDE (macOS Apple Silicon)',
|
||||
description: 'IDE thi thực hành — macOS chip Apple ARM',
|
||||
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE_arm-installer.dmg',
|
||||
platform: 'macOS',
|
||||
},
|
||||
{
|
||||
name: 'RESCIDE (Windows)',
|
||||
description: 'IDE thi thực hành — Windows 10+',
|
||||
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE-1.73.100-win.zip',
|
||||
platform: 'Windows',
|
||||
},
|
||||
];
|
||||
|
||||
export const RESCIDE_FEATURES = [
|
||||
'Kiểm soát code thời gian thực, cộng hưởng cùng RAIA DESKTOP chặn gian lận.',
|
||||
'Ngăn chặn paste code từ bên ngoài workspace.',
|
||||
'Chặn trình duyệt online và extension AI.',
|
||||
'Chặn Emmet / snippet code gian lận và nhiều chức năng ẩn khác.',
|
||||
] as const;
|
||||
@@ -80,9 +80,61 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
if err := purgeLegacyWifiWithoutBSSID(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := seedRescideAppDownloads(db); err != nil {
|
||||
log.Printf("Warning: seed RESCIDE app downloads: %v", err)
|
||||
}
|
||||
return migrateLegacyDiscoveredApps(db)
|
||||
}
|
||||
|
||||
// seedRescideAppDownloads thêm link tải RESCIDE nếu chưa có (quản lý tiếp tại tab Ứng dụng — super-admin).
|
||||
func seedRescideAppDownloads(db *gorm.DB) error {
|
||||
type seedItem struct {
|
||||
Name string
|
||||
Description string
|
||||
DownloadURL string
|
||||
Platform string
|
||||
}
|
||||
items := []seedItem{
|
||||
{
|
||||
Name: "RESCIDE (macOS Intel)",
|
||||
Description: "IDE thi thực hành — macOS chip Intel",
|
||||
DownloadURL: "https://save.rikkeiraia.org/RESCIDE_intel-installer.dmg",
|
||||
Platform: "macOS",
|
||||
},
|
||||
{
|
||||
Name: "RESCIDE (macOS Apple Silicon)",
|
||||
Description: "IDE thi thực hành — macOS chip Apple ARM",
|
||||
DownloadURL: "https://save.rikkeiraia.org/RESCIDE_arm-installer.dmg",
|
||||
Platform: "macOS",
|
||||
},
|
||||
{
|
||||
Name: "RESCIDE (Windows)",
|
||||
Description: "IDE thi thực hành — Windows 10+",
|
||||
DownloadURL: "https://save.rikkeiraia.org/RESCIDE-1.73.100-win.zip",
|
||||
Platform: "Windows",
|
||||
},
|
||||
}
|
||||
for _, item := range items {
|
||||
var count int64
|
||||
if err := db.Model(&models.AppDownload{}).Where("download_url = ?", item.DownloadURL).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
if err := db.Create(&models.AppDownload{
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
DownloadURL: item.DownloadURL,
|
||||
Platform: item.Platform,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Seeded app download: %s", item.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bảng WiFi cũ dùng ssid_key làm PK; schema mới cần id + bssid_key — GORM không tự đổi PK.
|
||||
func migrateLegacyWifiTables(db *gorm.DB) error {
|
||||
if db.Migrator().HasTable("accepted_wifis") {
|
||||
|
||||
Reference in New Issue
Block a user