mirror of
https://github.com/pyrohost/pyrodactyl.git
synced 2026-04-06 04:01:58 +02:00
feat: tons of incomplete features for modrinth panel(TODO)
This commit is contained in:
@@ -69,6 +69,6 @@ See our development pages on how to get started:
|
||||
|
||||
Pterodactyl® Copyright © 2015 - 2022 Dane Everitt and contributors.
|
||||
|
||||
Pyrodactyl™ Copyright © 2024-2025 Pyro Inc. and contributors.
|
||||
Pyrodactyl™ Copyright © 2025 Pyro Inc. and contributors.
|
||||
|
||||
AGPL-3.0-or-later
|
||||
|
||||
988
composer.lock
generated
988
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,12 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
const CheckboxArrow = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'> & { label?: string; onChange?: () => void }
|
||||
>(({ className, label, onChange, ...props }, ref) => {
|
||||
React.ComponentPropsWithoutRef<'div'> & { label?: string; onChange?: () => void; toggleable?: boolean }
|
||||
>(({ className, label, onChange, toggleable = true, ...props }, ref) => {
|
||||
const [checked, setChecked] = React.useState(false);
|
||||
|
||||
const toggleChecked = () => {
|
||||
if (!toggleable) return;
|
||||
setChecked((prev) => {
|
||||
const newCheckedState = !prev;
|
||||
if (onChange) onChange();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { faDownload } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -8,8 +10,7 @@ import HugeIconsDownload from '@/components/elements/hugeicons/Download';
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
import DownloadModModel from './DownloadModel';
|
||||
// import { useProjects } from './FetchProjects';
|
||||
import { apiEndpoints, offset, settings } from './config';
|
||||
import { apiEndpoints, offset, perpage, settings } from './config';
|
||||
|
||||
interface Project {
|
||||
project_id: string;
|
||||
@@ -34,19 +35,19 @@ interface Props {
|
||||
|
||||
const ProjectSelector: React.FC<Props> = ({ appVersion, baseUrl, nonApiUrl }) => {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isModalVisible, setModalVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [modalProject, setModalProject] = useState<string | null>(null);
|
||||
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
setIsLoading(true); // Start loading
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const facets = [
|
||||
settings.loaders.length > 0 ? settings.loaders.map((loader) => `categories:${loader}`) : null,
|
||||
settings.versions.length > 0 ? settings.versions.map((version) => `versions:${version}`) : null,
|
||||
settings.environments.length > 0
|
||||
? settings.environments.map((environment) => `project_type:${environment}`)
|
||||
? settings.environments.map((env) => `project_type:${env}`)
|
||||
: ['project_type:mod'],
|
||||
].filter(Boolean);
|
||||
|
||||
@@ -54,7 +55,9 @@ const ProjectSelector: React.FC<Props> = ({ appVersion, baseUrl, nonApiUrl }) =>
|
||||
facets: JSON.stringify(facets),
|
||||
index: 'relevance',
|
||||
offset: `${offset}`,
|
||||
limit: `${perpage}`,
|
||||
});
|
||||
|
||||
const query = settings.searchTerms.replace(/ /g, '-');
|
||||
const apiUrl = `${baseUrl}${apiEndpoints.projects}?${searchParams.toString()}&query=${query}`;
|
||||
|
||||
@@ -65,12 +68,12 @@ const ProjectSelector: React.FC<Props> = ({ appVersion, baseUrl, nonApiUrl }) =>
|
||||
},
|
||||
});
|
||||
|
||||
const updatedProjects = response.data.hits.map((project: Project) => ({
|
||||
...project,
|
||||
icon_url: project.icon_url || 'N/A',
|
||||
}));
|
||||
|
||||
setProjects(updatedProjects);
|
||||
setProjects(
|
||||
response.data.hits.map((project: Project) => ({
|
||||
...project,
|
||||
icon_url: project.icon_url || 'N/A',
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error('Failed to fetch projects.');
|
||||
console.error('Error fetching projects:', error);
|
||||
@@ -84,20 +87,10 @@ const ProjectSelector: React.FC<Props> = ({ appVersion, baseUrl, nonApiUrl }) =>
|
||||
}, []);
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1_000_000_000) {
|
||||
return (num / 1_000_000_000).toFixed(1) + 'B';
|
||||
} else if (num >= 1_000_000) {
|
||||
return (num / 1_000_000).toFixed(1) + 'M';
|
||||
} else if (num >= 1_000) {
|
||||
return (num / 1_000).toFixed(1) + 'K';
|
||||
} else {
|
||||
return num.toString();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
// Implement your download logic here
|
||||
console.log('Downloading mod...');
|
||||
if (num >= 1_000_000_000) return (num / 1_000_000_000).toFixed(1) + 'B';
|
||||
if (num >= 1_000_000) return (num / 1_000_000).toFixed(1) + 'M';
|
||||
if (num >= 1_000) return (num / 1_000).toFixed(1) + 'K';
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -110,19 +103,19 @@ const ProjectSelector: React.FC<Props> = ({ appVersion, baseUrl, nonApiUrl }) =>
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Fetch Projects'}
|
||||
</button>
|
||||
<p></p>
|
||||
|
||||
{isLoading ? (
|
||||
<p className='text-white'>Loading projects...</p>
|
||||
) : projects.length > 0 ? (
|
||||
projects.map((project) => (
|
||||
<ContentBox
|
||||
key={project.project_id}
|
||||
className='p-4 bg-[#ffffff09] border-[1px] border-white shadow-sm rounded-xl w-full mb-4 relative'
|
||||
className='p-4 bg-[#ffffff09] border border-gray-600 shadow-sm rounded-xl w-full mb-4'
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<ContentBox className=' pt-1 rounded-xl mr-4 '>
|
||||
<ContentBox className='pt-1 rounded-xl mr-4'>
|
||||
<a href={`${nonApiUrl}/mod/${project.project_id}`} target='_blank' rel='noreferrer'>
|
||||
{project.icon_url && project.icon_url !== 'N/A' ? (
|
||||
{project.icon_url !== 'N/A' ? (
|
||||
<img src={project.icon_url} className='w-24 h-20 object-contain rounded' />
|
||||
) : (
|
||||
<svg
|
||||
@@ -156,36 +149,29 @@ const ProjectSelector: React.FC<Props> = ({ appVersion, baseUrl, nonApiUrl }) =>
|
||||
</p>
|
||||
<p className='text-sm text-gray-400'>{project.description}</p>
|
||||
</div>
|
||||
<div className='flex flex-col py-2 whitespace-nowrap px-6 mx-6 justify-end'>
|
||||
{/* Downloads */}
|
||||
<p className='text-sm inline-block whitespace-nowrap'>
|
||||
{formatNumber(project.downloads)}{' '}
|
||||
<p className='text-gray-600 inline ml-1'>Downloads</p>
|
||||
<div className='flex flex-col py-2 px-6 mx-6'>
|
||||
<p className='text-sm'>
|
||||
{formatNumber(project.downloads)} <span className='text-gray-600'>Downloads</span>
|
||||
</p>
|
||||
{/* Version */}
|
||||
<p className='text-sm inline inline-block pt-2 whitespace-nowrap'>
|
||||
{project.versions[project.versions.length - 1]}
|
||||
<p className='text-gray-600 inline ml-2'>Latest</p>
|
||||
<p className='text-sm pt-2'>
|
||||
{project.versions.at(-1)} <span className='text-gray-600'>Latest</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-col py-2 whitespace-nowrap px-6 mx-6 justify-end'>
|
||||
{/* Install */}
|
||||
<a className='pt-4'>
|
||||
<button
|
||||
className='flex text-right border-2 border-solid rounded py-1 px-6 border-brand hover:border-white transition ease-in-out delay-300 hover:bg-red-600 hover:scale-110'
|
||||
onClick={() => setModalVisible(true)}
|
||||
>
|
||||
<HugeIconsDownload className='px-2 mx-2' fill='currentColor' />
|
||||
Install
|
||||
</button>
|
||||
{isModalVisible && (
|
||||
<DownloadModModel
|
||||
modid={project.project_id}
|
||||
visible={isModalVisible} // This is needed if `asModal` adds extra props
|
||||
onModalDismissed={() => setModalVisible(false)}
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
<div className='flex flex-col py-2 px-6 mx-6'>
|
||||
<button
|
||||
className='flex items-center border-2 border-solid rounded py-1 px-6 border-brand hover:border-white hover:bg-red-600 hover:scale-110 justify-center'
|
||||
onClick={() => setModalProject(project.project_id)}
|
||||
>
|
||||
<HugeIconsDownload className='px-2 mx-2' fill='currentColor' /> Install
|
||||
</button>
|
||||
{modalProject === project.project_id && (
|
||||
<DownloadModModel
|
||||
modid={project.project_id}
|
||||
modName={project.title}
|
||||
visible
|
||||
onModalDismissed={() => setModalProject(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ContentBox>
|
||||
|
||||
@@ -10,64 +10,69 @@ interface DownloadProps {
|
||||
|
||||
const DownloadModrinth: React.FC<DownloadProps> = ({ url, serverUuid, directory = 'mods' }) => {
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const downloadAndUploadFile = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Download the file
|
||||
const downloadResponse = await axios({
|
||||
method: 'GET',
|
||||
url: url,
|
||||
toast.info('Downloading file from Modrinth...');
|
||||
|
||||
// 1️⃣ Download the file from Modrinth
|
||||
const downloadResponse = await axios.get(url, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const fileName = url.split('/').pop();
|
||||
const fileName = url.split('/').pop() || 'modrinth-file.jar';
|
||||
const file = new Blob([downloadResponse.data], {
|
||||
type: downloadResponse.headers['content-type'],
|
||||
type: downloadResponse.headers['content-type'] || 'application/java-archive',
|
||||
});
|
||||
|
||||
// 2️⃣ Prepare FormData for Upload
|
||||
const formData = new FormData();
|
||||
formData.append('files', file, fileName);
|
||||
|
||||
// Upload the file
|
||||
const uploadResponse = await axios.post(`/api/client/servers/${serverUuid}/files/upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
params: {
|
||||
directory: `/container/${directory}`,
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
setProgress(percentCompleted);
|
||||
// 3️⃣ Upload to Pyrodactyl Server
|
||||
toast.info(`Uploading ${fileName} to server...`);
|
||||
await axios.post(`/api/client/servers/${serverUuid}/files/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
params: { directory: `/container/${directory}` },
|
||||
onUploadProgress: (event) => {
|
||||
if (event.total) {
|
||||
setProgress(Math.round((event.loaded * 100) / event.total));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
toast.success('File uploaded successfully!');
|
||||
return uploadResponse.data;
|
||||
toast.success(`${fileName} uploaded successfully!`);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (error: any) => {
|
||||
if (axios.isCancel(error)) {
|
||||
console.log('Request cancelled:', error.message);
|
||||
toast.warning('Request cancelled.');
|
||||
} else if (error.response) {
|
||||
toast.error(`Server error! Status: ${error.response.status}`);
|
||||
console.error(`Server error! Status: ${error.response.status}`);
|
||||
} else if (error.request) {
|
||||
toast.error('No response received from server.');
|
||||
console.error('No response received from server.', error.request);
|
||||
toast.error('No response from server.');
|
||||
} else {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
console.error(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={downloadAndUploadFile}>Download & Upload File</button>
|
||||
{progress > 0 && <p>Upload Progress: {progress}%</p>}
|
||||
<div className='p-4'>
|
||||
<button
|
||||
onClick={downloadAndUploadFile}
|
||||
disabled={loading}
|
||||
className='px-4 py-2 bg-blue-500 text-white rounded-lg'
|
||||
>
|
||||
{loading ? 'Processing...' : 'Download & Upload'}
|
||||
</button>
|
||||
{progress > 0 && <p className='mt-2 text-sm'>Upload Progress: {progress}%</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { faDownload } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
// NOTE: This should be a middleware instead of whatever the fuck this is
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import ItemContainer from '@/components/elements/ItemContainer';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import DropdownButton from '@/components/server/modrinth/Dropdown';
|
||||
|
||||
import asModal from '@/hoc/asModal';
|
||||
|
||||
import { ExpandableScrollBox, type ScrollItem } from './scroll-dropdown';
|
||||
|
||||
interface ModVersion {
|
||||
id: string;
|
||||
version_number: string;
|
||||
@@ -26,6 +26,12 @@ const DownloadModModal = ({ modid, modName }: Props) => {
|
||||
const [versions, setVersions] = useState<ModVersion[]>([]);
|
||||
const [visibleCount, setVisibleCount] = useState(5);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedItem, setSelectedItem] = useState<ScrollItem | null>(null);
|
||||
|
||||
const handleSelect = (item: ScrollItem) => {
|
||||
setSelectedItem(item);
|
||||
console.log(`Selected: ${item.label}`);
|
||||
};
|
||||
|
||||
// Fetch mod versions from Modrinth API
|
||||
useEffect(() => {
|
||||
@@ -39,54 +45,40 @@ const DownloadModModal = ({ modid, modName }: Props) => {
|
||||
.catch(() => setLoading(false));
|
||||
}, [modid]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
setVisibleCount((prev) => prev + 5);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='p-6 w-full'>
|
||||
<h2 className='text-2xl font-bold text-white mb-4'>{modName}</h2>
|
||||
<FlashMessageRender byKey={`mod-download-${modid}`} />
|
||||
<div className='p-6 mb-12 w-full overscroll-none'>
|
||||
<h2 className='text-2xl font-bold text-white mb-4 text-center '>{modName}</h2>
|
||||
<div aria-hidden className='my-8 bg-[#ffffff33] min-h-[1px]'></div>
|
||||
|
||||
<FlashMessageRender byKey={`mod-download-${modid}`} />
|
||||
{loading ? (
|
||||
<p className='text-white'>Loading versions...</p>
|
||||
) : versions.length === 0 ? (
|
||||
<p className='text-white'>No versions available for this mod.</p>
|
||||
<p className='text-white'>No versions available for this mod. 😔</p>
|
||||
) : (
|
||||
<>
|
||||
{versions.slice(0, visibleCount).map((version) => (
|
||||
<ItemContainer
|
||||
key={version.id}
|
||||
className='flex items-center justify-between py-2 border-b w-full'
|
||||
>
|
||||
<div className='flex flex-col text-left w-full'>
|
||||
<span className='text-lg font-semibold text-white'>
|
||||
Version: {version.version_number}
|
||||
</span>
|
||||
<span className='text-sm text-white'>
|
||||
Published: {new Date(version.date_published).toLocaleDateString()}
|
||||
</span>
|
||||
<span className='text-sm text-white'>Downloads: {version.downloads}</span>
|
||||
</div>
|
||||
<Button
|
||||
className='ml-4'
|
||||
onClick={() => {
|
||||
const file = version.files[0];
|
||||
if (file) {
|
||||
window.open(file.url, '_blank');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} className='mr-2' /> Download
|
||||
</Button>
|
||||
</ItemContainer>
|
||||
))}
|
||||
{visibleCount < versions.length && (
|
||||
<Button className='mt-4' onClick={handleLoadMore}>
|
||||
Load More
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='w-full max-w-sm space-y-8'>
|
||||
<h1 className='text-2xl font-bold text-center text-custom-light-gray'>
|
||||
<span className='text-custom-red'>Selection</span> Box
|
||||
</h1>
|
||||
|
||||
<ExpandableScrollBox
|
||||
placeholder='Select an option'
|
||||
items={versions}
|
||||
maxHeight='250px'
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
|
||||
{/* Display selected item */}
|
||||
<div className='p-4 bg-custom-dark-gray rounded-md text-custom-light-gray text-center'>
|
||||
{selectedItem ? `You selected: ${selectedItem.label}` : 'No option selected yet'}
|
||||
</div>
|
||||
|
||||
<div className='text-sm text-custom-light-gray text-center mt-4 opacity-70'>
|
||||
The selected item appears in the button after selection
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
75
resources/scripts/components/server/modrinth/Dropdown.tsx
Normal file
75
resources/scripts/components/server/modrinth/Dropdown.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { ChevronDownIcon } from '@radix-ui/react-icons';
|
||||
import axios from 'axios';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Button from '../../elements/ButtonV2';
|
||||
import { persistent, settings } from './config';
|
||||
|
||||
interface ApiRequest {
|
||||
game_versions: string[];
|
||||
loaders: string[];
|
||||
id: string;
|
||||
project_id: string;
|
||||
author_id: string;
|
||||
featured: boolean;
|
||||
name: string;
|
||||
version_number: string;
|
||||
changelog: string;
|
||||
changelog_url: string | null;
|
||||
date_published: string;
|
||||
downloads: number;
|
||||
version_type: string;
|
||||
status: string;
|
||||
requested_status: string | null;
|
||||
files: ApiFiles[];
|
||||
}
|
||||
|
||||
interface ApiFiles {
|
||||
hashes: { sha512: string; sha1: string };
|
||||
url: string;
|
||||
filename: string;
|
||||
primary: boolean;
|
||||
size: number;
|
||||
file_type: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
list: ApiRequest[];
|
||||
}
|
||||
|
||||
const DropdownButton = ({ list }: Props) => {
|
||||
const [selected, setSelected] = useState(list[0]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className='flex justify-center'>
|
||||
<div className='relative inline-flex flex-col items-center w-3/4'>
|
||||
<Button
|
||||
className='flex items-center justify-between w-full px-4 py-2 rounded-lg overflow-show'
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<span className='truncate'>Selected Version: {list[0].version_number}</span>
|
||||
<ChevronDownIcon className='w-4 h-4 ml-2' />
|
||||
</Button>
|
||||
{open && (
|
||||
<div className='absolute mt-1 w-full bg-[#ffffff09] border rounded-sm shadow-lg text-white'>
|
||||
{list.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className='px-4 my-2 cursor-pointer text-white hover:bg-gray-700'
|
||||
onClick={() => {
|
||||
setSelected(option);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownButton;
|
||||
@@ -34,7 +34,7 @@ const EnvironmentSelector: React.FC<Props> = ({ items, onSelectionChange }) => {
|
||||
{items.length > 5 && (
|
||||
<div onClick={fetchNewProjects()}>
|
||||
<div className='flex items-center gap-2 cursor-pointer mt-2' onClick={() => setShowAll(!showAll)}>
|
||||
<CheckboxArrow label={showAll ? 'Show Less' : 'Show More'} />
|
||||
<CheckboxArrow label={showAll ? 'Show Less' : 'Show More'} toggleable={false} />
|
||||
<ArrowDownIcon
|
||||
className={`transform transition-transform ${showAll ? 'rotate-180' : ''}`}
|
||||
fill='currentColor'
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { ScrollMenu } from '@/components/elements/ScrollMenu';
|
||||
import { ScrollMenu } from '@/components/server/modrinth/ScrollMenu';
|
||||
import Checkbox from '@/components/elements/inputs/Checkbox';
|
||||
|
||||
import { apiEndpoints, fetchHeaders, settings } from './config';
|
||||
import { apiEndpoints, fetchHeaders, persistent, settings } from './config';
|
||||
|
||||
interface GameVersion {
|
||||
version: string;
|
||||
@@ -38,6 +38,7 @@ const GameVersionSelector: React.FC<Props> = ({ appVersion, baseUrl }) => {
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data)) {
|
||||
setMinecraftVersions(data);
|
||||
persistent.gameVersions = data;
|
||||
} else {
|
||||
throw new Error('Invalid data format received from API.');
|
||||
}
|
||||
@@ -62,7 +63,6 @@ const GameVersionSelector: React.FC<Props> = ({ appVersion, baseUrl }) => {
|
||||
|
||||
const handleSelectionChange = (selectedItems: string[]) => {
|
||||
settings.versions = selectedItems;
|
||||
console.log('Updated settings.versions:', settings.versions);
|
||||
};
|
||||
|
||||
const handleSnapshotToggle = () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import debounce from 'debounce';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -18,6 +19,7 @@ import { settings as localSettings } from './config';
|
||||
|
||||
export default () => {
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null);
|
||||
|
||||
const [settings, setSettings] = useState({
|
||||
loaders: [],
|
||||
versions: [],
|
||||
@@ -28,8 +30,6 @@ export default () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const updateSearchTerms = () => {
|
||||
// settings.searchTerms = setSearchTerm;
|
||||
// settings.searchTerms = setSearchTerm;
|
||||
localSettings.searchTerms = searchTerm;
|
||||
fetchNewProjects();
|
||||
return debounce(setSearchTerm, 50);
|
||||
@@ -44,9 +44,8 @@ export default () => {
|
||||
useEffect(() => {
|
||||
async function getAppVersion() {
|
||||
try {
|
||||
const response = await fetch('/api/client/version');
|
||||
const data = await response.json();
|
||||
setAppVersion(data.version); // Set the app version state
|
||||
const response = await axios.get('/api/client/version');
|
||||
setAppVersion(response.data.version);
|
||||
} catch (error) {
|
||||
toast.error('Failed to fetch app version.');
|
||||
}
|
||||
@@ -62,18 +61,11 @@ export default () => {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
// Function to update settings
|
||||
const updateSettings = (key: keyof typeof settings, value: any) => {
|
||||
setSettings((prevSettings) => ({ ...prevSettings, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
toast.success(`Searching for: ${searchQuery}`);
|
||||
console.log(searchQuery);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContentBlock title={'Mods/Plugins'}>
|
||||
<ContentBox className='p-8 bg-[#ffffff09] border-[1px] border-[#ffffff11] shadow-sm rounded-xl mb-5'>
|
||||
{/* TODO: Add a navbar to cycle between Downloaded, Download, and Dependency resolver*/}
|
||||
</ContentBox>
|
||||
<div className='flex flex-wrap gap-4'>
|
||||
<ContentBox
|
||||
className='p-8 bg-[#ffffff09] border-[1px] border-[#ffffff11] shadow-sm rounded-xl md:w-1/6'
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Checkbox } from '@/components/elements/CheckboxLabel';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { fetchNewProjects } from '../server/modrinth/config';
|
||||
import { fetchNewProjects } from './config';
|
||||
|
||||
interface Props {
|
||||
appVersion;
|
||||
@@ -5,10 +5,13 @@ interface Settings {
|
||||
searchTerms: string;
|
||||
}
|
||||
|
||||
export const gameLoaders = [];
|
||||
export const gamerVersions = [];
|
||||
export const persistent = {
|
||||
gameLoaders: [],
|
||||
gameVersions: [],
|
||||
};
|
||||
|
||||
export const offset = 0;
|
||||
export const perpage = 25;
|
||||
|
||||
export const apiEndpoints = {
|
||||
projects: `/search`,
|
||||
|
||||
107
resources/scripts/components/server/modrinth/scroll-dropdown.tsx
Normal file
107
resources/scripts/components/server/modrinth/scroll-dropdown.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ScrollItem {
|
||||
id: string | number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ExpandableScrollBoxProps {
|
||||
placeholder?: string;
|
||||
items: ScrollItem[];
|
||||
maxHeight?: string;
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
boxClassName?: string;
|
||||
onSelect?: (item: ScrollItem) => void;
|
||||
}
|
||||
|
||||
export function ExpandableScrollBox({
|
||||
placeholder = 'Select an option',
|
||||
items = [],
|
||||
maxHeight = '300px',
|
||||
className = '',
|
||||
buttonClassName = '',
|
||||
boxClassName = '',
|
||||
onSelect,
|
||||
}: ExpandableScrollBoxProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [selectedItem, setSelectedItem] = React.useState<ScrollItem | null>(null);
|
||||
|
||||
const handleSelect = (item: ScrollItem) => {
|
||||
setSelectedItem(item);
|
||||
setIsOpen(false);
|
||||
if (onSelect) {
|
||||
onSelect(item);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
{/* Selection Button */}
|
||||
<button
|
||||
className={cn(
|
||||
'w-full px-6 py-3 rounded-md font-medium',
|
||||
'bg-custom-red text-white',
|
||||
'hover:bg-custom-red-hover focus:outline-none focus:ring-2 focus:ring-custom-red-hover focus:ring-offset-2 focus:ring-offset-black',
|
||||
'transition-colors duration-200 shadow-md',
|
||||
'flex items-center justify-between',
|
||||
buttonClassName,
|
||||
)}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls='scroll-box'
|
||||
>
|
||||
<span className='truncate'>{selectedItem ? selectedItem.label : placeholder}</span>
|
||||
{isOpen ? (
|
||||
<ChevronUpIcon className='ml-2 h-5 w-5 flex-shrink-0' />
|
||||
) : (
|
||||
<ChevronDownIcon className='ml-2 h-5 w-5 flex-shrink-0' />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expandable Box with Scroll Menu */}
|
||||
<div
|
||||
id='scroll-box'
|
||||
className={cn(
|
||||
'w-full mt-4 rounded-md overflow-hidden',
|
||||
'bg-custom-medium-gray border-2 border-custom-dark-gray',
|
||||
'shadow-lg transition-all duration-300 ease-in-out',
|
||||
isOpen ? 'max-h-[var(--max-height)] opacity-100 my-4' : 'max-h-0 opacity-0 my-0 border-0',
|
||||
boxClassName,
|
||||
)}
|
||||
style={{ '--max-height': maxHeight } as React.CSSProperties}
|
||||
>
|
||||
{/* Scroll Container */}
|
||||
<div
|
||||
className={cn('overflow-y-auto transition-all', isOpen ? 'opacity-100' : 'opacity-0')}
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
<div className='py-1'>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'px-4 py-3 cursor-pointer transition-colors duration-150',
|
||||
'flex items-center justify-between',
|
||||
'hover:bg-custom-dark-gray text-white',
|
||||
selectedItem?.id === item.id
|
||||
? 'bg-custom-dark-gray border-l-2 border-custom-red'
|
||||
: 'border-l-2 border-transparent',
|
||||
)}
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{selectedItem?.id === item.id && <Check className='h-4 w-4 text-custom-red' />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import Field from '@/components/elements/Field';
|
||||
import Modal from '@/components/elements/Modal';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
export default () => {
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{({ isSubmitting, resetForm }) => (
|
||||
<Modal
|
||||
visible={visible}
|
||||
dismissable={!isSubmitting}
|
||||
showSpinnerOverlay={isSubmitting}
|
||||
onDismissed={() => {
|
||||
resetForm();
|
||||
setVisible(false);
|
||||
}}
|
||||
title='Create new database'
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<FlashMessageRender byKey={'database:create'} />
|
||||
<Form>
|
||||
<Field
|
||||
type={'string'}
|
||||
id={'database_name'}
|
||||
name={'databaseName'}
|
||||
label={'Database Name'}
|
||||
description={'A descriptive name for your database instance.'}
|
||||
/>
|
||||
<div className={`mt-6`}>
|
||||
<Field
|
||||
type={'string'}
|
||||
id={'connections_from'}
|
||||
name={'connectionsFrom'}
|
||||
label={'Connections From'}
|
||||
description={
|
||||
'Where connections should be allowed from. Leave blank to allow connections from anywhere.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={`flex gap-3 justify-end my-6`}>
|
||||
<Button type={'submit'}>Create Database</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<button
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(124.75% 124.75% at 50.01% -10.55%, rgb(36, 36, 36) 0%, rgb(20, 20, 20) 100%)',
|
||||
}}
|
||||
className='px-8 py-3 border-[1px] border-[#ffffff12] rounded-full text-sm font-bold shadow-md'
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
New Database
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,7 @@
|
||||
// I know it's deprecated! We need to fix it!!!
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import App from '@/components/App';
|
||||
|
||||
Sentry.init({
|
||||
// This is safe to be public.
|
||||
// See https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/ for more information.
|
||||
dsn: 'https://b25e7066a7d647cea237cd72beec5c9f@app.glitchtip.com/6107',
|
||||
integrations: [],
|
||||
});
|
||||
|
||||
const container = document.getElementById('app');
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
|
||||
@@ -246,7 +246,9 @@ export default () => {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<MainSidebar className={`${isSidebarVisible ? '' : 'hidden'} lg:flex`}>
|
||||
<MainSidebar
|
||||
className={`${isSidebarVisible ? '' : 'hidden'} lg:flex fixed inset-y-0 left-0 z-[9999] w-[300px] bg-[#1a1a1a]`}
|
||||
>
|
||||
<div
|
||||
className='absolute bg-brand w-[3px] h-10 left-0 rounded-full pointer-events-none'
|
||||
style={{
|
||||
@@ -286,7 +288,7 @@ export default () => {
|
||||
</svg>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className='z-[99999] select-none' sideOffset={8}>
|
||||
<DropdownMenuContent className='z-[99999] select-none relative' sideOffset={8}>
|
||||
{rootAdmin && (
|
||||
<DropdownMenuItem onSelect={onSelectManageServer}>
|
||||
Manage Server
|
||||
@@ -307,6 +309,7 @@ export default () => {
|
||||
className='flex flex-row items-center'
|
||||
ref={NavigationHome}
|
||||
to={`/server/${id}`}
|
||||
onClick={toggleSidebar}
|
||||
end
|
||||
>
|
||||
<HugeIconsHome fill='currentColor' />
|
||||
@@ -319,6 +322,7 @@ export default () => {
|
||||
className='flex flex-row items-center'
|
||||
ref={NavigationFiles}
|
||||
to={`/server/${id}/files`}
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<HugeIconsFolder fill='currentColor' />
|
||||
<p>Files</p>
|
||||
@@ -329,6 +333,7 @@ export default () => {
|
||||
className='flex flex-row items-center'
|
||||
ref={NavigationDatabases}
|
||||
to={`/server/${id}/databases`}
|
||||
onClick={toggleSidebar}
|
||||
end
|
||||
>
|
||||
<HugeIconsDatabase fill='currentColor' />
|
||||
@@ -340,6 +345,7 @@ export default () => {
|
||||
className='flex flex-row items-center'
|
||||
ref={NavigationBackups}
|
||||
to={`/server/${id}/backups`}
|
||||
onClick={toggleSidebar}
|
||||
end
|
||||
>
|
||||
<HugeIconsCloudUp fill='currentColor' />
|
||||
@@ -351,6 +357,7 @@ export default () => {
|
||||
className='flex flex-row items-center'
|
||||
ref={NavigationNetworking}
|
||||
to={`/server/${id}/network`}
|
||||
onClick={toggleSidebar}
|
||||
end
|
||||
>
|
||||
<HugeIconsConnections fill='currentColor' />
|
||||
@@ -362,6 +369,7 @@ export default () => {
|
||||
className='flex flex-row items-center'
|
||||
ref={NavigationUsers}
|
||||
to={`/server/${id}/users`}
|
||||
onClick={toggleSidebar}
|
||||
end
|
||||
>
|
||||
<HugeIconsPeople fill='currentColor' />
|
||||
@@ -373,6 +381,7 @@ export default () => {
|
||||
className='flex flex-row items-center'
|
||||
ref={NavigationStartup}
|
||||
to={`/server/${id}/startup`}
|
||||
onClick={toggleSidebar}
|
||||
end
|
||||
>
|
||||
<HugeIconsConsole fill='currentColor' />
|
||||
@@ -384,6 +393,7 @@ export default () => {
|
||||
className='flex flex-row items-center'
|
||||
ref={NavigationSchedules}
|
||||
to={`/server/${id}/schedules`}
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<HugeIconsClock fill='currentColor' />
|
||||
<p>Schedules</p>
|
||||
@@ -394,6 +404,7 @@ export default () => {
|
||||
className='flex flex-row items-center'
|
||||
ref={NavigationSettings}
|
||||
to={`/server/${id}/settings`}
|
||||
onClick={toggleSidebar}
|
||||
end
|
||||
>
|
||||
<HugeIconsDashboardSettings fill='currentColor' />
|
||||
@@ -405,23 +416,26 @@ export default () => {
|
||||
className='flex flex-row items-center'
|
||||
ref={NavigationActivity}
|
||||
to={`/server/${id}/activity`}
|
||||
onClick={toggleSidebar}
|
||||
end
|
||||
>
|
||||
<HugeIconsPencil fill='currentColor' />
|
||||
<p>Activity</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
<Can action={['modrinth.*', 'modrinth.download']} matchAny>
|
||||
{/* TODO: finish modrinth support *\}
|
||||
{/* <Can action={['modrinth.*', 'modrinth.download']} matchAny>
|
||||
<NavLink
|
||||
className='flex flex-row items-center'
|
||||
className='flex flex-row items-center sm:hidden md:show'
|
||||
ref={NavigationMod}
|
||||
to={`/server/${id}/mods`}
|
||||
onClick={toggleSidebar}
|
||||
end
|
||||
>
|
||||
<ModrinthLogo />
|
||||
<p>Mods</p>
|
||||
<p>Mods/Plugins</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
</Can> */}
|
||||
</>
|
||||
)}
|
||||
<Can action={'startup.software'}>
|
||||
@@ -429,6 +443,7 @@ export default () => {
|
||||
className='flex flex-row items-center'
|
||||
ref={NavigationShell}
|
||||
to={`/server/${id}/shell`}
|
||||
onClick={toggleSidebar}
|
||||
end
|
||||
>
|
||||
<HugeIconsController fill='currentColor' />
|
||||
|
||||
142
resources/views/admin/settings/captcha.blade.php
Normal file
142
resources/views/admin/settings/captcha.blade.php
Normal file
@@ -0,0 +1,142 @@
|
||||
@extends('layouts.admin')
|
||||
@include('partials/admin.settings.nav', ['activeTab' => 'captcha'])
|
||||
|
||||
@section('title')
|
||||
Captcha Settings
|
||||
@endsection
|
||||
|
||||
@section('content-header')
|
||||
<h1>Captcha Settings<small>Configure captcha settings for Pyrodactyl.</small></h1>
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{{ route('admin.index') }}">Admin</a></li>
|
||||
<li class="active">Settings</li>
|
||||
</ol>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
@yield('settings::nav')
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<form action="" method="POST">
|
||||
<div class="box">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">reCAPTCHA</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<div class="row">
|
||||
<div class="form-group col-md-4">
|
||||
<label class="control-label">Status</label>
|
||||
<div>
|
||||
<select class="form-control" name="recaptcha:enabled">
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false" @if(old('recaptcha:enabled', config('recaptcha.enabled')) == '0') selected @endif>
|
||||
Disabled</option>
|
||||
</select>
|
||||
<p class="text-muted small">If enabled, login forms and password reset forms will do a silent captcha
|
||||
check and display a visible captcha if needed.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-4">
|
||||
<label class="control-label">Site Key</label>
|
||||
<div>
|
||||
<input type="text" required class="form-control" name="recaptcha:website_key"
|
||||
value="{{ old('recaptcha:website_key', config('recaptcha.website_key')) }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-4">
|
||||
<label class="control-label">Secret Key</label>
|
||||
<div>
|
||||
<input type="text" required class="form-control" name="recaptcha:secret_key"
|
||||
value="{{ old('recaptcha:secret_key', config('recaptcha.secret_key')) }}">
|
||||
<p class="text-muted small">Used for communication between your site and Google. Be sure to keep it a
|
||||
secret.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if($showRecaptchaWarning)
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<div class="alert alert-warning no-margin">
|
||||
You are currently using reCAPTCHA keys that were shipped with this Panel. For improved security it is
|
||||
recommended to <a target="_blank" href="https://www.google.com/recaptcha/admin">generate new invisible
|
||||
reCAPTCHA
|
||||
keys</a> that tied specifically to your website.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">HTTP Connections</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<div class="row">
|
||||
<div class="form-group col-md-6">
|
||||
<label class="control-label">Connection Timeout</label>
|
||||
<div>
|
||||
<input type="number" required class="form-control" name="pterodactyl:guzzle:connect_timeout"
|
||||
value="{{ old('pterodactyl:guzzle:connect_timeout', config('pterodactyl.guzzle.connect_timeout')) }}">
|
||||
<p class="text-muted small">The amount of time in seconds to wait for a connection to be opened before
|
||||
throwing an error.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label class="control-label">Request Timeout</label>
|
||||
<div>
|
||||
<input type="number" required class="form-control" name="pterodactyl:guzzle:timeout"
|
||||
value="{{ old('pterodactyl:guzzle:timeout', config('pterodactyl.guzzle.timeout')) }}">
|
||||
<p class="text-muted small">The amount of time in seconds to wait for a request to be completed before
|
||||
throwing an error.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">Automatic Allocation Creation</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<div class="row">
|
||||
<div class="form-group col-md-4">
|
||||
<label class="control-label">Status</label>
|
||||
<div>
|
||||
<select class="form-control" name="pterodactyl:client_features:allocations:enabled">
|
||||
<option value="false">Disabled</option>
|
||||
<option value="true" @if(old('pterodactyl:client_features:allocations:enabled', config('pterodactyl.client_features.allocations.enabled'))) selected @endif>Enabled</option>
|
||||
</select>
|
||||
<p class="text-muted small">If enabled users will have the option to automatically create new
|
||||
allocations for their server via the frontend.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-4">
|
||||
<label class="control-label">Starting Port</label>
|
||||
<div>
|
||||
<input type="number" class="form-control" name="pterodactyl:client_features:allocations:range_start"
|
||||
value="{{ old('pterodactyl:client_features:allocations:range_start', config('pterodactyl.client_features.allocations.range_start')) }}">
|
||||
<p class="text-muted small">The starting port in the range that can be automatically allocated.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-4">
|
||||
<label class="control-label">Ending Port</label>
|
||||
<div>
|
||||
<input type="number" class="form-control" name="pterodactyl:client_features:allocations:range_end"
|
||||
value="{{ old('pterodactyl:client_features:allocations:range_end', config('pterodactyl.client_features.allocations.range_end')) }}">
|
||||
<p class="text-muted small">The ending port in the range that can be automatically allocated.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box box-primary">
|
||||
<div class="box-footer">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" name="_method" value="PATCH" class="btn btn-sm btn-primary pull-right">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -9,7 +9,8 @@
|
||||
<li @if($activeTab === 'basic')class="active"@endif><a href="{{ route('admin.settings') }}">General</a></li>
|
||||
<li @if($activeTab === 'mail')class="active"@endif><a href="{{ route('admin.settings.mail') }}">Mail</a></li>
|
||||
<li @if($activeTab === 'advanced')class="active"@endif><a href="{{ route('admin.settings.advanced') }}">Advanced</a></li>
|
||||
</ul>
|
||||
<li @if($activeTab === 'captcha')class="active"@endif><a href="{{ route('admin.settings.captcha') }}">Captcha</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user