feat: Add showYoutubeSearch feature
This commit is contained in:
@@ -27,6 +27,7 @@ interface Settings {
|
||||
websiteName?: string;
|
||||
itemsPerPage?: number;
|
||||
ytDlpConfig?: string;
|
||||
showYoutubeSearch?: boolean;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
@@ -44,6 +45,7 @@ const defaultSettings: Settings = {
|
||||
subtitlesEnabled: true,
|
||||
websiteName: "MyTube",
|
||||
itemsPerPage: 12,
|
||||
showYoutubeSearch: true,
|
||||
};
|
||||
|
||||
export const getSettings = async (_req: Request, res: Response) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, FormControl, InputLabel, MenuItem, Select, TextField, Typography } from '@mui/material';
|
||||
import { Box, FormControl, FormControlLabel, InputLabel, MenuItem, Select, Switch, TextField, Typography } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
@@ -6,11 +6,12 @@ interface GeneralSettingsProps {
|
||||
language: string;
|
||||
websiteName?: string;
|
||||
itemsPerPage?: number;
|
||||
onChange: (field: string, value: string | number) => void;
|
||||
showYoutubeSearch?: boolean;
|
||||
onChange: (field: string, value: string | number | boolean) => void;
|
||||
}
|
||||
|
||||
const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
|
||||
const { language, websiteName, onChange } = props;
|
||||
const { language, websiteName, showYoutubeSearch, onChange } = props;
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
@@ -63,6 +64,16 @@ const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
|
||||
helperText={t('itemsPerPageHelper') || "Number of videos to show per page (Default: 12)"}
|
||||
slotProps={{ htmlInput: { min: 1 } }}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={showYoutubeSearch ?? true}
|
||||
onChange={(e) => onChange('showYoutubeSearch', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('showYoutubeSearch') || "Show YouTube Search Results"}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -25,9 +25,6 @@ const TagsSettings: React.FC<TagsSettingsProps> = ({ tags, onTagsChange }) => {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>{t('tagsManagement') || 'Tags Management'}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('tagsManagementNote') || 'Please remember to click "Save Settings" after adding or removing tags to apply changes.'}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
|
||||
{tags && tags.map((tag) => (
|
||||
<Chip
|
||||
|
||||
@@ -28,6 +28,7 @@ interface VideoContextType {
|
||||
availableTags: string[];
|
||||
selectedTags: string[];
|
||||
handleTagToggle: (tag: string) => void;
|
||||
showYoutubeSearch: boolean;
|
||||
}
|
||||
|
||||
const VideoContext = createContext<VideoContextType | undefined>(undefined);
|
||||
@@ -63,17 +64,20 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
retryDelay: 1000,
|
||||
});
|
||||
|
||||
// Tags Query
|
||||
const { data: availableTags = [] } = useQuery({
|
||||
queryKey: ['tags'],
|
||||
// Settings Query (tags and showYoutubeSearch)
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
return response.data.tags || [];
|
||||
return response.data;
|
||||
},
|
||||
retry: 10,
|
||||
retryDelay: 1000,
|
||||
});
|
||||
|
||||
const availableTags = settingsData?.tags || [];
|
||||
const showYoutubeSearch = settingsData?.showYoutubeSearch ?? true;
|
||||
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
|
||||
// Search state
|
||||
@@ -168,25 +172,32 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
const localResults = searchLocalVideos(query);
|
||||
setLocalSearchResults(localResults);
|
||||
|
||||
setYoutubeLoading(true);
|
||||
// Only search YouTube if showYoutubeSearch is enabled
|
||||
if (showYoutubeSearch) {
|
||||
setYoutubeLoading(true);
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/search`, {
|
||||
params: { query },
|
||||
signal: signal
|
||||
});
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/search`, {
|
||||
params: { query },
|
||||
signal: signal
|
||||
});
|
||||
|
||||
if (!signal.aborted) {
|
||||
setSearchResults(response.data.results);
|
||||
}
|
||||
} catch (youtubeErr: any) {
|
||||
if (youtubeErr.name !== 'CanceledError' && youtubeErr.name !== 'AbortError') {
|
||||
console.error('Error searching YouTube:', youtubeErr);
|
||||
}
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
setYoutubeLoading(false);
|
||||
if (!signal.aborted) {
|
||||
setSearchResults(response.data.results);
|
||||
}
|
||||
} catch (youtubeErr: any) {
|
||||
if (youtubeErr.name !== 'CanceledError' && youtubeErr.name !== 'AbortError') {
|
||||
console.error('Error searching YouTube:', youtubeErr);
|
||||
}
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
setYoutubeLoading(false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Clear any existing YouTube results when disabled
|
||||
setSearchResults([]);
|
||||
setYoutubeLoading(false);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
@@ -311,7 +322,8 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
setIsSearchMode,
|
||||
availableTags,
|
||||
selectedTags,
|
||||
handleTagToggle
|
||||
handleTagToggle,
|
||||
showYoutubeSearch
|
||||
}}>
|
||||
{children}
|
||||
</VideoContext.Provider>
|
||||
|
||||
17
frontend/src/hooks/useDebounce.ts
Normal file
17
frontend/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@@ -29,7 +29,8 @@ const SearchPage: React.FC = () => {
|
||||
searchResults,
|
||||
youtubeLoading,
|
||||
handleSearch,
|
||||
searchTerm: contextSearchTerm
|
||||
searchTerm: contextSearchTerm,
|
||||
showYoutubeSearch
|
||||
} = useVideo();
|
||||
const { collections } = useCollection();
|
||||
const { handleVideoSubmit } = useDownload();
|
||||
@@ -95,6 +96,7 @@ const SearchPage: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
{/* YouTube Search Results */}
|
||||
{showYoutubeSearch && (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: '#ff0000' }}>
|
||||
{t('fromYouTube')}
|
||||
@@ -164,6 +166,7 @@ const SearchPage: React.FC = () => {
|
||||
<Typography color="text.secondary">{t('noYouTubeResults')}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,7 +29,8 @@ const SearchResults: React.FC = () => {
|
||||
youtubeLoading,
|
||||
deleteVideo,
|
||||
resetSearch,
|
||||
setIsSearchMode
|
||||
setIsSearchMode,
|
||||
showYoutubeSearch
|
||||
} = useVideo();
|
||||
const { collections } = useCollection();
|
||||
const { handleVideoSubmit } = useDownload();
|
||||
@@ -75,8 +76,8 @@ const SearchResults: React.FC = () => {
|
||||
}
|
||||
|
||||
const hasLocalResults = localSearchResults && localSearchResults.length > 0;
|
||||
const hasYouTubeResults = searchResults && searchResults.length > 0;
|
||||
const noResults = !hasLocalResults && !hasYouTubeResults && !youtubeLoading;
|
||||
const hasYouTubeResults = showYoutubeSearch && searchResults && searchResults.length > 0;
|
||||
const noResults = !hasLocalResults && !hasYouTubeResults && (!showYoutubeSearch || !youtubeLoading);
|
||||
|
||||
// Helper function to format view count
|
||||
const formatViewCount = (count?: number) => {
|
||||
@@ -130,74 +131,76 @@ const SearchResults: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
{/* YouTube Search Results */}
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: '#ff0000' }}>
|
||||
From YouTube
|
||||
</Typography>
|
||||
{showYoutubeSearch && (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: '#ff0000' }}>
|
||||
From YouTube
|
||||
</Typography>
|
||||
|
||||
{youtubeLoading ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
|
||||
<CircularProgress color="error" />
|
||||
<Typography sx={{ mt: 2 }}>Loading YouTube results...</Typography>
|
||||
</Box>
|
||||
) : hasYouTubeResults ? (
|
||||
<Grid container spacing={3}>
|
||||
{searchResults.map((result) => <Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ position: 'relative', paddingTop: '56.25%' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={result.thumbnailUrl || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
|
||||
alt={result.title}
|
||||
sx={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.onerror = null;
|
||||
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
||||
}}
|
||||
/>
|
||||
{result.duration && (
|
||||
<Chip
|
||||
label={formatDuration(result.duration)}
|
||||
size="small"
|
||||
sx={{ position: 'absolute', bottom: 8, right: 8, bgcolor: 'rgba(0,0,0,0.8)', color: 'white' }}
|
||||
{youtubeLoading ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
|
||||
<CircularProgress color="error" />
|
||||
<Typography sx={{ mt: 2 }}>Loading YouTube results...</Typography>
|
||||
</Box>
|
||||
) : hasYouTubeResults ? (
|
||||
<Grid container spacing={3}>
|
||||
{searchResults.map((result) => <Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ position: 'relative', paddingTop: '56.25%' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={result.thumbnailUrl || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
|
||||
alt={result.title}
|
||||
sx={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.onerror = null;
|
||||
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
|
||||
{result.source === 'bilibili' ? <OndemandVideo sx={{ color: '#23ade5' }} /> : <YouTube sx={{ color: '#ff0000' }} />}
|
||||
{result.duration && (
|
||||
<Chip
|
||||
label={formatDuration(result.duration)}
|
||||
size="small"
|
||||
sx={{ position: 'absolute', bottom: 8, right: 8, bgcolor: 'rgba(0,0,0,0.8)', color: 'white' }}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
|
||||
{result.source === 'bilibili' ? <OndemandVideo sx={{ color: '#23ade5' }} /> : <YouTube sx={{ color: '#ff0000' }} />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<CardContent sx={{ flexGrow: 1, p: 2 }}>
|
||||
<Typography gutterBottom variant="subtitle1" component="div" sx={{ fontWeight: 600, lineHeight: 1.2, mb: 1, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{result.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{result.author}
|
||||
</Typography>
|
||||
{result.viewCount && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatViewCount(result.viewCount)} views
|
||||
<CardContent sx={{ flexGrow: 1, p: 2 }}>
|
||||
<Typography gutterBottom variant="subtitle1" component="div" sx={{ fontWeight: 600, lineHeight: 1.2, mb: 1, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{result.title}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardActions sx={{ p: 2, pt: 0 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<Download />}
|
||||
onClick={() => handleDownload(result.sourceUrl)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{result.author}
|
||||
</Typography>
|
||||
{result.viewCount && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatViewCount(result.viewCount)} views
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardActions sx={{ p: 2, pt: 0 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<Download />}
|
||||
onClick={() => handleDownload(result.sourceUrl)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography color="text.secondary">No YouTube results found.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography color="text.secondary">No YouTube results found.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import {
|
||||
Save
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Container,
|
||||
@@ -13,7 +10,7 @@ import {
|
||||
Snackbar,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
@@ -29,6 +26,7 @@ import VideoDefaultSettings from '../components/Settings/VideoDefaultSettings';
|
||||
import YtDlpSettings from '../components/Settings/YtDlpSettings';
|
||||
import { useDownload } from '../contexts/DownloadContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import { Settings } from '../types';
|
||||
import ConsoleManager from '../utils/consoleManager';
|
||||
import { SNACKBAR_AUTO_HIDE_DURATION } from '../utils/constants';
|
||||
@@ -37,7 +35,6 @@ import { Language } from '../utils/translations';
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t, setLanguage } = useLanguage();
|
||||
const { activeDownloads } = useDownload();
|
||||
|
||||
@@ -54,9 +51,12 @@ const SettingsPage: React.FC = () => {
|
||||
openListToken: '',
|
||||
cloudDrivePath: '',
|
||||
itemsPerPage: 12,
|
||||
ytDlpConfig: ''
|
||||
ytDlpConfig: '',
|
||||
showYoutubeSearch: true
|
||||
});
|
||||
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
|
||||
const debouncedSettings = useDebounce(settings, 1000);
|
||||
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||
|
||||
// Modal states
|
||||
const [showDeleteLegacyModal, setShowDeleteLegacyModal] = useState(false);
|
||||
@@ -82,13 +82,21 @@ const SettingsPage: React.FC = () => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsData) {
|
||||
if (settingsData && isFirstLoad) {
|
||||
setSettings({
|
||||
...settingsData,
|
||||
tags: settingsData.tags || []
|
||||
});
|
||||
setIsFirstLoad(false);
|
||||
}
|
||||
}, [settingsData]);
|
||||
}, [settingsData, isFirstLoad]);
|
||||
|
||||
// Autosave effect
|
||||
useEffect(() => {
|
||||
if (!isFirstLoad) {
|
||||
saveMutation.mutate(debouncedSettings);
|
||||
}
|
||||
}, [debouncedSettings]);
|
||||
|
||||
// Save settings mutation
|
||||
const saveMutation = useMutation({
|
||||
@@ -101,19 +109,15 @@ const SettingsPage: React.FC = () => {
|
||||
await axios.post(`${API_URL}/settings`, settingsToSend);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
// Do not invalidate queries to prevent overwriting user input while typing
|
||||
setMessage({ text: t('settingsSaved'), type: 'success' });
|
||||
// Clear password field after save
|
||||
setSettings(prev => ({ ...prev, password: '', isPasswordSet: true }));
|
||||
},
|
||||
onError: () => {
|
||||
setMessage({ text: t('settingsFailed'), type: 'error' });
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
saveMutation.mutate(settings);
|
||||
};
|
||||
|
||||
|
||||
// Migrate data mutation
|
||||
const migrateMutation = useMutation({
|
||||
@@ -302,6 +306,7 @@ const SettingsPage: React.FC = () => {
|
||||
language={settings.language}
|
||||
websiteName={settings.websiteName}
|
||||
itemsPerPage={settings.itemsPerPage}
|
||||
showYoutubeSearch={settings.showYoutubeSearch}
|
||||
onChange={(field, value) => handleChange(field as keyof Settings, value)}
|
||||
/>
|
||||
</Grid>
|
||||
@@ -401,19 +406,8 @@ const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<Save />}
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? t('saving') : t('saveSettings')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -72,4 +72,5 @@ export interface Settings {
|
||||
websiteName?: string;
|
||||
itemsPerPage?: number;
|
||||
ytDlpConfig?: string;
|
||||
showYoutubeSearch?: boolean;
|
||||
}
|
||||
|
||||
@@ -52,9 +52,6 @@ export const en = {
|
||||
tagsManagement: "Tags Management",
|
||||
newTag: "New Tag",
|
||||
tags: "Tags",
|
||||
tagsManagementNote:
|
||||
'Please remember to click "Save Settings" after adding or removing tags to apply changes.',
|
||||
|
||||
// Database
|
||||
database: "Database",
|
||||
migrateDataDescription:
|
||||
@@ -107,6 +104,7 @@ export const en = {
|
||||
formatFilenamesError: "Failed to format filenames: {error}",
|
||||
itemsPerPage: "Items Per Page",
|
||||
itemsPerPageHelper: "Number of videos to show per page (Default: 12)",
|
||||
showYoutubeSearch: "Show YouTube Search Results",
|
||||
cleanupTempFilesSuccess: "Successfully deleted {count} temporary file(s).",
|
||||
cleanupTempFilesFailed: "Failed to clean up temporary files",
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ export const zh = {
|
||||
"有活动下载时无法清理。请等待所有下载完成或取消它们。",
|
||||
itemsPerPage: "每页显示数量",
|
||||
itemsPerPageHelper: "每页显示的视频数量 (默认: 12)",
|
||||
showYoutubeSearch: "显示 YouTube 搜索结果",
|
||||
cleanupTempFilesSuccess: "成功删除了 {count} 个临时文件。",
|
||||
cleanupTempFilesFailed: "清理临时文件失败",
|
||||
|
||||
|
||||
Reference in New Issue
Block a user