feat: refactor IntegrationFilter for Kanban and Dashboard compatibility
- Updated IntegrationFilter to support both Kanban and Dashboard modes with new filters for manual tasks. - Replaced SourceQuickFilter with IntegrationFilter in Desktop and Mobile controls for consistency. - Removed deprecated SourceQuickFilter component to streamline codebase. - Enhanced task filtering logic to include pinned tasks and manual task visibility.
This commit is contained in:
@@ -3,12 +3,18 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { Dropdown, Button } from '@/components/ui';
|
||||
import type { KanbanFilters } from '@/lib/types';
|
||||
|
||||
interface IntegrationFilterProps {
|
||||
selectedSources: string[];
|
||||
onSourcesChange: (sources: string[]) => void;
|
||||
hiddenSources: string[];
|
||||
onHiddenSourcesChange: (sources: string[]) => void;
|
||||
// Interface pour Kanban (nouvelle)
|
||||
filters?: KanbanFilters;
|
||||
onFiltersChange?: (filters: KanbanFilters) => void;
|
||||
|
||||
// Interface pour Dashboard (ancienne)
|
||||
selectedSources?: string[];
|
||||
onSourcesChange?: (sources: string[]) => void;
|
||||
hiddenSources?: string[];
|
||||
onHiddenSourcesChange?: (sources: string[]) => void;
|
||||
}
|
||||
|
||||
interface SourceOption {
|
||||
@@ -20,15 +26,24 @@ interface SourceOption {
|
||||
|
||||
type FilterMode = 'all' | 'show' | 'hide';
|
||||
|
||||
export function IntegrationFilter({ selectedSources, onSourcesChange, hiddenSources, onHiddenSourcesChange }: IntegrationFilterProps) {
|
||||
const { regularTasks } = useTasksContext();
|
||||
export function IntegrationFilter({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
selectedSources,
|
||||
onSourcesChange,
|
||||
hiddenSources,
|
||||
onHiddenSourcesChange
|
||||
}: IntegrationFilterProps) {
|
||||
const { regularTasks, pinnedTasks } = useTasksContext();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Vérifier quelles sources ont des tâches
|
||||
// Vérifier quelles sources ont des tâches (regularTasks + pinnedTasks)
|
||||
const sources = useMemo((): SourceOption[] => {
|
||||
const hasJiraTasks = regularTasks.some(task => task.source === 'jira');
|
||||
const hasTfsTasks = regularTasks.some(task => task.source === 'tfs');
|
||||
const hasManualTasks = regularTasks.some(task => task.source === 'manual');
|
||||
const allTasks = [...regularTasks, ...pinnedTasks];
|
||||
const hasJiraTasks = allTasks.some(task => task.source === 'jira');
|
||||
const hasTfsTasks = allTasks.some(task => task.source === 'tfs');
|
||||
const hasManualTasks = allTasks.some(task => task.source === 'manual');
|
||||
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -50,7 +65,7 @@ export function IntegrationFilter({ selectedSources, onSourcesChange, hiddenSour
|
||||
hasTasks: hasManualTasks
|
||||
}
|
||||
].filter(source => source.hasTasks);
|
||||
}, [regularTasks]);
|
||||
}, [regularTasks, pinnedTasks]);
|
||||
|
||||
// Si aucune source disponible, on n'affiche rien
|
||||
if (sources.length === 0) {
|
||||
@@ -58,7 +73,49 @@ export function IntegrationFilter({ selectedSources, onSourcesChange, hiddenSour
|
||||
}
|
||||
|
||||
|
||||
const handleModeChange = (sourceId: string, mode: FilterMode) => {
|
||||
// Déterminer le mode d'utilisation (Kanban ou Dashboard)
|
||||
const isKanbanMode = filters && onFiltersChange;
|
||||
const isDashboardMode = selectedSources && onSourcesChange && hiddenSources && onHiddenSourcesChange;
|
||||
|
||||
// Déterminer l'état actuel de chaque source
|
||||
const getSourceMode = (sourceId: 'jira' | 'tfs' | 'manual'): FilterMode => {
|
||||
if (isKanbanMode && filters) {
|
||||
if (sourceId === 'jira') {
|
||||
return filters.showJiraOnly ? 'show' : filters.hideJiraTasks ? 'hide' : 'all';
|
||||
} else if (sourceId === 'tfs') {
|
||||
return filters.showTfsOnly ? 'show' : filters.hideTfsTasks ? 'hide' : 'all';
|
||||
} else { // manual
|
||||
return filters.showManualOnly ? 'show' : filters.hideManualTasks ? 'hide' : 'all';
|
||||
}
|
||||
} else if (isDashboardMode && selectedSources && hiddenSources) {
|
||||
if (selectedSources.includes(sourceId)) {
|
||||
return 'show';
|
||||
} else if (hiddenSources.includes(sourceId)) {
|
||||
return 'hide';
|
||||
} else {
|
||||
return 'all';
|
||||
}
|
||||
}
|
||||
return 'all';
|
||||
};
|
||||
|
||||
const handleModeChange = (sourceId: 'jira' | 'tfs' | 'manual', mode: FilterMode) => {
|
||||
if (isKanbanMode && filters && onFiltersChange) {
|
||||
const updates: Partial<KanbanFilters> = {};
|
||||
|
||||
if (sourceId === 'jira') {
|
||||
updates.showJiraOnly = mode === 'show';
|
||||
updates.hideJiraTasks = mode === 'hide';
|
||||
} else if (sourceId === 'tfs') {
|
||||
updates.showTfsOnly = mode === 'show';
|
||||
updates.hideTfsTasks = mode === 'hide';
|
||||
} else if (sourceId === 'manual') {
|
||||
updates.showManualOnly = mode === 'show';
|
||||
updates.hideManualTasks = mode === 'hide';
|
||||
}
|
||||
|
||||
onFiltersChange({ ...filters, ...updates });
|
||||
} else if (isDashboardMode && onSourcesChange && onHiddenSourcesChange && selectedSources && hiddenSources) {
|
||||
let newSelectedSources = [...selectedSources];
|
||||
let newHiddenSources = [...hiddenSources];
|
||||
|
||||
@@ -82,29 +139,41 @@ export function IntegrationFilter({ selectedSources, onSourcesChange, hiddenSour
|
||||
|
||||
onHiddenSourcesChange(newHiddenSources);
|
||||
onSourcesChange(newSelectedSources);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Déterminer la variante du bouton principal
|
||||
const getMainButtonVariant = () => {
|
||||
const activeFilters = sources.filter(source => {
|
||||
const mode = getSourceMode(source.id);
|
||||
return mode !== 'all';
|
||||
});
|
||||
|
||||
return activeFilters.length === 0 ? 'secondary' : 'selected';
|
||||
};
|
||||
|
||||
const getMainButtonText = () => {
|
||||
if (selectedSources.length === 0 && hiddenSources.length === 0) {
|
||||
return 'Toutes les sources';
|
||||
} else if (selectedSources.length === 1 && hiddenSources.length === 0) {
|
||||
const source = sources.find(s => s.id === selectedSources[0]);
|
||||
return source ? `Seulement ${source.label}` : 'Source sélectionnée';
|
||||
} else if (hiddenSources.length === 1 && selectedSources.length === 0) {
|
||||
const source = sources.find(s => s.id === hiddenSources[0]);
|
||||
return source ? `Sans ${source.label}` : 'Source masquée';
|
||||
const activeFilters = sources.filter(source => {
|
||||
const mode = getSourceMode(source.id);
|
||||
return mode !== 'all';
|
||||
});
|
||||
|
||||
if (activeFilters.length === 0) {
|
||||
return 'Sources';
|
||||
} else if (activeFilters.length === 1) {
|
||||
const source = activeFilters[0];
|
||||
const mode = getSourceMode(source.id);
|
||||
return mode === 'show' ? `Seulement ${source.label}` : `Sans ${source.label}`;
|
||||
} else {
|
||||
const total = selectedSources.length + hiddenSources.length;
|
||||
return `${total} filtres actifs`;
|
||||
return `${activeFilters.length} filtres actifs`;
|
||||
}
|
||||
};
|
||||
|
||||
const dropdownContent = (
|
||||
<div className="space-y-3">
|
||||
{sources.map((source) => {
|
||||
const isSelected = selectedSources.includes(source.id);
|
||||
const isHidden = hiddenSources.includes(source.id);
|
||||
const currentMode = getSourceMode(source.id);
|
||||
|
||||
return (
|
||||
<div key={source.id} className="space-y-2">
|
||||
@@ -118,11 +187,13 @@ export function IntegrationFilter({ selectedSources, onSourcesChange, hiddenSour
|
||||
{/* Bouton Afficher */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleModeChange(source.id, 'show');
|
||||
}}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
isSelected
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors cursor-pointer ${
|
||||
currentMode === 'show'
|
||||
? 'bg-[var(--primary)] text-[var(--primary-foreground)]'
|
||||
: 'bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--primary)]/20'
|
||||
}`}
|
||||
@@ -134,11 +205,13 @@ export function IntegrationFilter({ selectedSources, onSourcesChange, hiddenSour
|
||||
{/* Bouton Masquer */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleModeChange(source.id, 'hide');
|
||||
}}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
isHidden
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors cursor-pointer ${
|
||||
currentMode === 'hide'
|
||||
? 'bg-[var(--destructive)] text-white'
|
||||
: 'bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--destructive)]/20'
|
||||
}`}
|
||||
@@ -158,13 +231,25 @@ export function IntegrationFilter({ selectedSources, onSourcesChange, hiddenSour
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (isKanbanMode && filters && onFiltersChange) {
|
||||
const updates: Partial<KanbanFilters> = {
|
||||
showJiraOnly: false,
|
||||
hideJiraTasks: false,
|
||||
showTfsOnly: false,
|
||||
hideTfsTasks: false,
|
||||
showManualOnly: false,
|
||||
hideManualTasks: false
|
||||
};
|
||||
onFiltersChange({ ...filters, ...updates });
|
||||
} else if (isDashboardMode && onHiddenSourcesChange && onSourcesChange) {
|
||||
onHiddenSourcesChange([]);
|
||||
onSourcesChange([]);
|
||||
}
|
||||
}}
|
||||
className="w-full justify-start font-mono"
|
||||
title="Réinitialiser tous les filtres de source"
|
||||
>
|
||||
<span>🔄</span>
|
||||
<span>🔄 </span>
|
||||
<span className="flex-1">Réinitialiser tout</span>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -176,7 +261,10 @@ export function IntegrationFilter({ selectedSources, onSourcesChange, hiddenSour
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
trigger={`🔗 ${getMainButtonText()}`}
|
||||
variant={getMainButtonVariant()}
|
||||
content={dropdownContent}
|
||||
placement="bottom-start"
|
||||
className="min-w-[240px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Button, ToggleButton, SearchInput, ControlPanel, ControlSection, ControlGroup } from '@/components/ui';
|
||||
import { SourceQuickFilter } from '@/components/kanban/SourceQuickFilter';
|
||||
import { IntegrationFilter } from '@/components/dashboard/IntegrationFilter';
|
||||
import { FontSizeToggle } from '@/components/ui/FontSizeToggle';
|
||||
import type { KanbanFilters } from '@/lib/types';
|
||||
|
||||
@@ -134,7 +134,7 @@ export function DesktopControls({
|
||||
<ControlSection className="justify-between lg:justify-start">
|
||||
<ControlGroup className="border-l border-[var(--border)] ml-2 pl-2 pr-4">
|
||||
{/* Raccourcis Sources (Jira & TFS) */}
|
||||
<SourceQuickFilter
|
||||
<IntegrationFilter
|
||||
filters={kanbanFilters}
|
||||
onFiltersChange={onFiltersChange}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button, ToggleButton, ControlPanel } from '@/components/ui';
|
||||
import { SourceQuickFilter } from '@/components/kanban/SourceQuickFilter';
|
||||
import { IntegrationFilter } from '@/components/dashboard/IntegrationFilter';
|
||||
import { FontSizeToggle } from '@/components/ui/FontSizeToggle';
|
||||
import type { KanbanFilters } from '@/lib/types';
|
||||
|
||||
@@ -149,7 +149,7 @@ export function MobileControls({
|
||||
Sources
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<SourceQuickFilter
|
||||
<IntegrationFilter
|
||||
filters={kanbanFilters}
|
||||
onFiltersChange={onFiltersChange}
|
||||
/>
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTasksContext } from '@/contexts/TasksContext';
|
||||
import { Dropdown, Button } from '@/components/ui';
|
||||
import type { KanbanFilters } from '@/lib/types';
|
||||
|
||||
interface SourceQuickFilterProps {
|
||||
filters: KanbanFilters;
|
||||
onFiltersChange: (filters: KanbanFilters) => void;
|
||||
}
|
||||
|
||||
interface SourceOption {
|
||||
id: 'jira' | 'tfs';
|
||||
label: string;
|
||||
icon: string;
|
||||
hasTasks: boolean;
|
||||
}
|
||||
|
||||
type FilterMode = 'all' | 'show' | 'hide';
|
||||
|
||||
export function SourceQuickFilter({ filters, onFiltersChange }: SourceQuickFilterProps) {
|
||||
const { regularTasks } = useTasksContext();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Vérifier quelles sources ont des tâches
|
||||
const sources = useMemo((): SourceOption[] => {
|
||||
const hasJiraTasks = regularTasks.some(task => task.source === 'jira');
|
||||
const hasTfsTasks = regularTasks.some(task => task.source === 'tfs');
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'jira' as const,
|
||||
label: 'Jira',
|
||||
icon: '🔹',
|
||||
hasTasks: hasJiraTasks
|
||||
},
|
||||
{
|
||||
id: 'tfs' as const,
|
||||
label: 'TFS',
|
||||
icon: '🔷',
|
||||
hasTasks: hasTfsTasks
|
||||
}
|
||||
].filter(source => source.hasTasks);
|
||||
}, [regularTasks]);
|
||||
|
||||
|
||||
// Si aucune source disponible, on n'affiche rien
|
||||
if (sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Déterminer l'état actuel de chaque source
|
||||
const getSourceMode = (sourceId: 'jira' | 'tfs'): FilterMode => {
|
||||
if (sourceId === 'jira') {
|
||||
return filters.showJiraOnly ? 'show' : filters.hideJiraTasks ? 'hide' : 'all';
|
||||
} else {
|
||||
return filters.showTfsOnly ? 'show' : filters.hideTfsTasks ? 'hide' : 'all';
|
||||
}
|
||||
};
|
||||
|
||||
const handleModeChange = (sourceId: 'jira' | 'tfs', mode: FilterMode) => {
|
||||
const updates: Partial<KanbanFilters> = {};
|
||||
|
||||
if (sourceId === 'jira') {
|
||||
updates.showJiraOnly = mode === 'show';
|
||||
updates.hideJiraTasks = mode === 'hide';
|
||||
} else {
|
||||
updates.showTfsOnly = mode === 'show';
|
||||
updates.hideTfsTasks = mode === 'hide';
|
||||
}
|
||||
|
||||
onFiltersChange({ ...filters, ...updates });
|
||||
};
|
||||
|
||||
// Déterminer le texte du bouton principal
|
||||
const getMainButtonVariant = () => {
|
||||
const activeFilters = sources.filter(source => {
|
||||
const mode = getSourceMode(source.id);
|
||||
return mode !== 'all';
|
||||
});
|
||||
|
||||
return activeFilters.length === 0 ? 'secondary' : 'selected';
|
||||
};
|
||||
|
||||
const getMainButtonText = () => {
|
||||
const activeFilters = sources.filter(source => {
|
||||
const mode = getSourceMode(source.id);
|
||||
return mode !== 'all';
|
||||
});
|
||||
|
||||
if (activeFilters.length === 0) {
|
||||
return 'All sources';
|
||||
} else if (activeFilters.length === 1) {
|
||||
const source = activeFilters[0];
|
||||
const mode = getSourceMode(source.id);
|
||||
return mode === 'show' ? `${source.label} only` : `No ${source.label}`;
|
||||
} else {
|
||||
return `${activeFilters.length} filters`;
|
||||
}
|
||||
};
|
||||
|
||||
const dropdownContent = (
|
||||
<div className="space-y-3">
|
||||
{sources.map((source) => {
|
||||
const currentMode = getSourceMode(source.id);
|
||||
|
||||
return (
|
||||
<div key={source.id} className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-mono text-[var(--muted-foreground)]">
|
||||
<span>{source.icon}</span>
|
||||
<span>{source.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 ml-6">
|
||||
{[
|
||||
{ mode: 'all' as FilterMode, label: 'Afficher tout', icon: '👁️' },
|
||||
{ mode: 'show' as FilterMode, label: 'Seulement cette source', icon: '✅' },
|
||||
{ mode: 'hide' as FilterMode, label: 'Masquer cette source', icon: '🚫' }
|
||||
].map(({ mode, label, icon }) => (
|
||||
<label
|
||||
key={mode}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`source-${source.id}`}
|
||||
checked={currentMode === mode}
|
||||
onChange={() => handleModeChange(source.id, mode)}
|
||||
className="w-3 h-3 text-[var(--primary)] bg-[var(--background)] border-[var(--border)] focus:ring-[var(--primary)]/20"
|
||||
/>
|
||||
<span className="flex items-center gap-1">
|
||||
<span>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Option pour réinitialiser tous les filtres */}
|
||||
<div className="border-t border-[var(--border)] pt-2 mt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const updates: Partial<KanbanFilters> = {
|
||||
showJiraOnly: false,
|
||||
hideJiraTasks: false,
|
||||
showTfsOnly: false,
|
||||
hideTfsTasks: false
|
||||
};
|
||||
onFiltersChange({ ...filters, ...updates });
|
||||
}}
|
||||
className="w-full justify-start font-mono"
|
||||
title="Réinitialiser tous les filtres de source"
|
||||
>
|
||||
<span>🔄</span>
|
||||
<span className="flex-1">Réinitialiser tout</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
trigger={`🔌 ${getMainButtonText()}`}
|
||||
variant={getMainButtonVariant()}
|
||||
content={dropdownContent}
|
||||
placement="bottom-start"
|
||||
className="min-w-[240px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,10 @@ export function TasksProvider({ children, initialTasks, initialTags, initialStat
|
||||
// Filtres TFS
|
||||
showTfsOnly: preferences.kanbanFilters.showTfsOnly || false,
|
||||
hideTfsTasks: preferences.kanbanFilters.hideTfsTasks || false,
|
||||
tfsProjects: preferences.kanbanFilters.tfsProjects || []
|
||||
tfsProjects: preferences.kanbanFilters.tfsProjects || [],
|
||||
// Filtres Manuel
|
||||
showManualOnly: preferences.kanbanFilters.showManualOnly || false,
|
||||
hideManualTasks: preferences.kanbanFilters.hideManualTasks || false
|
||||
}), [preferences]);
|
||||
|
||||
// Fonction pour mettre à jour les filtres avec persistance
|
||||
@@ -92,7 +95,10 @@ export function TasksProvider({ children, initialTasks, initialTags, initialStat
|
||||
// Filtres TFS
|
||||
showTfsOnly: newFilters.showTfsOnly,
|
||||
hideTfsTasks: newFilters.hideTfsTasks,
|
||||
tfsProjects: newFilters.tfsProjects
|
||||
tfsProjects: newFilters.tfsProjects,
|
||||
// Filtres Manuel
|
||||
showManualOnly: newFilters.showManualOnly,
|
||||
hideManualTasks: newFilters.hideManualTasks
|
||||
};
|
||||
|
||||
const viewPreferenceUpdates = {
|
||||
@@ -151,7 +157,9 @@ export function TasksProvider({ children, initialTasks, initialTags, initialStat
|
||||
(kanbanFilters.hideJiraTasks ? 1 : 0) +
|
||||
(kanbanFilters.tfsProjects?.filter(Boolean).length || 0) +
|
||||
(kanbanFilters.showTfsOnly ? 1 : 0) +
|
||||
(kanbanFilters.hideTfsTasks ? 1 : 0);
|
||||
(kanbanFilters.hideTfsTasks ? 1 : 0) +
|
||||
(kanbanFilters.showManualOnly ? 1 : 0) +
|
||||
(kanbanFilters.hideManualTasks ? 1 : 0);
|
||||
}, [kanbanFilters]);
|
||||
|
||||
// Filtrage et tri des tâches régulières (pas les épinglées)
|
||||
@@ -212,6 +220,13 @@ export function TasksProvider({ children, initialTasks, initialTags, initialStat
|
||||
filtered = filtered.filter(task => task.source !== 'tfs');
|
||||
}
|
||||
|
||||
// Filtres spécifiques Manuel
|
||||
if (kanbanFilters.showManualOnly) {
|
||||
filtered = filtered.filter(task => task.source === 'manual');
|
||||
} else if (kanbanFilters.hideManualTasks) {
|
||||
filtered = filtered.filter(task => task.source !== 'manual');
|
||||
}
|
||||
|
||||
// Filtre par projets TFS
|
||||
if (kanbanFilters.tfsProjects?.length) {
|
||||
filtered = filtered.filter(task =>
|
||||
|
||||
@@ -92,6 +92,9 @@ export interface KanbanFilters {
|
||||
showTfsOnly?: boolean;
|
||||
hideTfsTasks?: boolean;
|
||||
tfsProjects?: string[];
|
||||
// Filtres spécifiques Manuel
|
||||
showManualOnly?: boolean;
|
||||
hideManualTasks?: boolean;
|
||||
[key: string]: string | string[] | TaskPriority[] | boolean | undefined;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user