mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-08-16 13:47:28 +02:00
Additional clean up
This commit is contained in:
parent
dcadada7d3
commit
70bebb9a56
60
CLAUDE.md
60
CLAUDE.md
@ -77,31 +77,51 @@ Without cleanup: browser crashes with memory leaks.
|
|||||||
- **toolResponseProcessor**: API response handling (single/zip/custom)
|
- **toolResponseProcessor**: API response handling (single/zip/custom)
|
||||||
- **toolOperationTracker**: FileContext integration utilities
|
- **toolOperationTracker**: FileContext integration utilities
|
||||||
|
|
||||||
**Tool Implementation Pattern**:
|
**Three Tool Patterns**:
|
||||||
1. Create hook in `frontend/src/hooks/tools/[toolname]/use[ToolName]Operation.ts`
|
|
||||||
2. Define parameters interface and validation
|
|
||||||
3. Implement `buildFormData` function for API requests
|
|
||||||
4. Configure `useToolOperation` with endpoints and settings
|
|
||||||
5. UI components consume the hook's state and actions
|
|
||||||
|
|
||||||
**Example Pattern** (see `useCompressOperation.ts`):
|
**Pattern 1: Single-File Tools** (Individual processing)
|
||||||
|
- Backend processes one file per API call
|
||||||
|
- Set `multiFileEndpoint: false`
|
||||||
|
- Examples: Compress, Rotate
|
||||||
```typescript
|
```typescript
|
||||||
export const useCompressOperation = () => {
|
return useToolOperation({
|
||||||
const { t } = useTranslation();
|
operationType: 'compress',
|
||||||
|
endpoint: '/api/v1/misc/compress-pdf',
|
||||||
return useToolOperation<CompressParameters>({
|
buildFormData: (params, file: File) => { /* single file */ },
|
||||||
operationType: 'compress',
|
multiFileEndpoint: false,
|
||||||
endpoint: '/api/v1/misc/compress-pdf',
|
filePrefix: 'compressed_'
|
||||||
buildFormData,
|
});
|
||||||
filePrefix: 'compressed_',
|
```
|
||||||
validateParams: (params) => { /* validation logic */ },
|
|
||||||
getErrorMessage: createStandardErrorHandler(t('compress.error.failed'))
|
**Pattern 2: Multi-File Tools** (Batch processing)
|
||||||
});
|
- Backend accepts `MultipartFile[]` arrays in single API call
|
||||||
};
|
- Set `multiFileEndpoint: true`
|
||||||
|
- Examples: Split, Merge, Overlay
|
||||||
|
```typescript
|
||||||
|
return useToolOperation({
|
||||||
|
operationType: 'split',
|
||||||
|
endpoint: '/api/v1/general/split-pages',
|
||||||
|
buildFormData: (params, files: File[]) => { /* all files */ },
|
||||||
|
multiFileEndpoint: true,
|
||||||
|
filePrefix: 'split_'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern 3: Complex Tools** (Custom processing)
|
||||||
|
- Tools with complex routing logic or non-standard processing
|
||||||
|
- Provide `customProcessor` for full control
|
||||||
|
- Examples: Convert, OCR
|
||||||
|
```typescript
|
||||||
|
return useToolOperation({
|
||||||
|
operationType: 'convert',
|
||||||
|
customProcessor: async (params, files) => { /* custom logic */ },
|
||||||
|
filePrefix: 'converted_'
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**Benefits**:
|
**Benefits**:
|
||||||
- **Consistent**: All tools follow same pattern and interface
|
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
|
||||||
|
- **Consistent**: All tools follow same pattern and interface
|
||||||
- **Maintainable**: Single responsibility hooks, easy to test and modify
|
- **Maintainable**: Single responsibility hooks, easy to test and modify
|
||||||
- **i18n Ready**: Built-in internationalization support
|
- **i18n Ready**: Built-in internationalization support
|
||||||
- **Type Safe**: Full TypeScript support with generic interfaces
|
- **Type Safe**: Full TypeScript support with generic interfaces
|
||||||
|
@ -31,14 +31,13 @@ const buildFormData = (parameters: CompressParameters, file: File): FormData =>
|
|||||||
|
|
||||||
export const useCompressOperation = () => {
|
export const useCompressOperation = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useToolOperation<CompressParameters>({
|
return useToolOperation<CompressParameters>({
|
||||||
operationType: 'compress',
|
operationType: 'compress',
|
||||||
endpoint: '/api/v1/misc/compress-pdf',
|
endpoint: '/api/v1/misc/compress-pdf',
|
||||||
buildFormData,
|
buildFormData,
|
||||||
filePrefix: 'compressed_',
|
filePrefix: 'compressed_',
|
||||||
singleFileMode: false, // Process files individually
|
1 multiFileEndpoint: false, // Individual API calls per file
|
||||||
timeout: 60000, // 1 minute timeout per file
|
|
||||||
validateParams: (params) => {
|
validateParams: (params) => {
|
||||||
if (params.compressionMethod === 'filesize' && !params.fileSizeValue) {
|
if (params.compressionMethod === 'filesize' && !params.fileSizeValue) {
|
||||||
return { valid: false, errors: [t('compress.validation.fileSizeRequired', 'File size value is required when using filesize method')] };
|
return { valid: false, errors: [t('compress.validation.fileSizeRequired', 'File size value is required when using filesize method')] };
|
||||||
|
@ -5,10 +5,8 @@ import { ConvertParameters } from './useConvertParameters';
|
|||||||
import { detectFileExtension } from '../../../utils/fileUtils';
|
import { detectFileExtension } from '../../../utils/fileUtils';
|
||||||
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
|
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
|
||||||
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||||
|
|
||||||
import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
|
import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
|
||||||
|
|
||||||
|
|
||||||
const shouldProcessFilesSeparately = (
|
const shouldProcessFilesSeparately = (
|
||||||
selectedFiles: File[],
|
selectedFiles: File[],
|
||||||
parameters: ConvertParameters
|
parameters: ConvertParameters
|
||||||
@ -31,18 +29,6 @@ const shouldProcessFilesSeparately = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createFileFromResponse = (
|
|
||||||
responseData: any,
|
|
||||||
headers: any,
|
|
||||||
originalFileName: string,
|
|
||||||
targetExtension: string
|
|
||||||
): File => {
|
|
||||||
const originalName = originalFileName.split('.')[0];
|
|
||||||
const fallbackFilename = `${originalName}_converted.${targetExtension}`;
|
|
||||||
|
|
||||||
return createFileFromApiResponse(responseData, headers, fallbackFilename);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildFormData = (parameters: ConvertParameters, selectedFiles: File[]): FormData => {
|
const buildFormData = (parameters: ConvertParameters, selectedFiles: File[]): FormData => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
@ -83,20 +69,66 @@ const buildFormData = (parameters: ConvertParameters, selectedFiles: File[]): Fo
|
|||||||
return formData;
|
return formData;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createFileFromResponse = (
|
||||||
|
responseData: any,
|
||||||
|
headers: any,
|
||||||
|
originalFileName: string,
|
||||||
|
targetExtension: string
|
||||||
|
): File => {
|
||||||
|
const originalName = originalFileName.split('.')[0];
|
||||||
|
const fallbackFilename = `${originalName}_converted.${targetExtension}`;
|
||||||
|
|
||||||
|
return createFileFromApiResponse(responseData, headers, fallbackFilename);
|
||||||
|
};
|
||||||
|
|
||||||
export const useConvertOperation = () => {
|
export const useConvertOperation = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const customConvertProcessor = useCallback(async (
|
||||||
|
parameters: ConvertParameters,
|
||||||
|
selectedFiles: File[]
|
||||||
|
): Promise<File[]> => {
|
||||||
|
const processedFiles: File[] = [];
|
||||||
|
const endpoint = getEndpointUrl(parameters.fromExtension, parameters.toExtension);
|
||||||
|
|
||||||
|
if (!endpoint) {
|
||||||
|
throw new Error('Unsupported conversion format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert-specific routing logic: decide batch vs individual processing
|
||||||
|
if (shouldProcessFilesSeparately(selectedFiles, parameters)) {
|
||||||
|
// Individual processing for complex cases (PDF→image, smart detection, etc.)
|
||||||
|
for (const file of selectedFiles) {
|
||||||
|
const formData = buildFormData(parameters, [file]);
|
||||||
|
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||||
|
|
||||||
|
const convertedFile = createFileFromResponse(response.data, response.headers, file.name, parameters.toExtension);
|
||||||
|
|
||||||
|
processedFiles.push(convertedFile);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Batch processing for simple cases (image→PDF combine)
|
||||||
|
const formData = buildFormData(parameters, selectedFiles);
|
||||||
|
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||||
|
|
||||||
|
const baseFilename = selectedFiles.length === 1
|
||||||
|
? selectedFiles[0].name
|
||||||
|
: 'converted_files';
|
||||||
|
|
||||||
|
const convertedFile = createFileFromResponse(response.data, response.headers, baseFilename, parameters.toExtension);
|
||||||
|
processedFiles.push(convertedFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedFiles;
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
return useToolOperation<ConvertParameters>({
|
return useToolOperation<ConvertParameters>({
|
||||||
operationType: 'convert',
|
operationType: 'convert',
|
||||||
endpoint: (params) => getEndpointUrl(params.fromExtension, params.toExtension) || '',
|
endpoint: '', // Not used with customProcessor but required
|
||||||
buildFormData: buildFormData, // Clean multi-file signature: (params, selectedFiles) => FormData
|
buildFormData, // Not used with customProcessor but required
|
||||||
filePrefix: 'converted_',
|
filePrefix: 'converted_',
|
||||||
responseHandler: {
|
customProcessor: customConvertProcessor, // Convert handles its own routing
|
||||||
type: 'single'
|
|
||||||
},
|
|
||||||
validateParams: (params) => {
|
validateParams: (params) => {
|
||||||
// Add any validation if needed
|
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
},
|
},
|
||||||
getErrorMessage: (error) => {
|
getErrorMessage: (error) => {
|
||||||
|
@ -87,8 +87,7 @@ export const useOCROperation = () => {
|
|||||||
try {
|
try {
|
||||||
const formData = buildFormData(file, parameters);
|
const formData = buildFormData(file, parameters);
|
||||||
const response = await axios.post('/api/v1/misc/ocr-pdf', formData, {
|
const response = await axios.post('/api/v1/misc/ocr-pdf', formData, {
|
||||||
responseType: "blob",
|
responseType: "blob"
|
||||||
timeout: 300000 // 5 minute timeout for OCR
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for HTTP errors
|
// Check for HTTP errors
|
||||||
@ -174,7 +173,6 @@ export const useOCROperation = () => {
|
|||||||
buildFormData, // Not used with customProcessor but required
|
buildFormData, // Not used with customProcessor but required
|
||||||
filePrefix: 'ocr_',
|
filePrefix: 'ocr_',
|
||||||
customProcessor: customOCRProcessor,
|
customProcessor: customOCRProcessor,
|
||||||
timeout: 300000, // 5 minute timeout for OCR
|
|
||||||
validateParams: (params) => {
|
validateParams: (params) => {
|
||||||
if (params.languages.length === 0) {
|
if (params.languages.length === 0) {
|
||||||
return { valid: false, errors: [t('ocr.validation.languageRequired', 'Please select at least one language for OCR processing.')] };
|
return { valid: false, errors: [t('ocr.validation.languageRequired', 'Please select at least one language for OCR processing.')] };
|
||||||
|
@ -8,7 +8,6 @@ export interface ApiCallsConfig<TParams = void> {
|
|||||||
buildFormData: (file: File, params: TParams) => FormData;
|
buildFormData: (file: File, params: TParams) => FormData;
|
||||||
filePrefix: string;
|
filePrefix: string;
|
||||||
responseHandler?: ResponseHandler;
|
responseHandler?: ResponseHandler;
|
||||||
timeout?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useToolApiCalls = <TParams = void>() => {
|
export const useToolApiCalls = <TParams = void>() => {
|
||||||
@ -39,7 +38,6 @@ export const useToolApiCalls = <TParams = void>() => {
|
|||||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||||
const response = await axios.post(endpoint, formData, {
|
const response = await axios.post(endpoint, formData, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
timeout: config.timeout || 120000,
|
|
||||||
cancelToken: cancelTokenRef.current.token
|
cancelToken: cancelTokenRef.current.token
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -18,38 +18,57 @@ export interface ValidationResult {
|
|||||||
export type { ProcessingProgress, ResponseHandler };
|
export type { ProcessingProgress, ResponseHandler };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for tool operations defining processing behavior and API integration
|
* Configuration for tool operations defining processing behavior and API integration.
|
||||||
|
*
|
||||||
|
* Supports three patterns:
|
||||||
|
* 1. Single-file tools: multiFileEndpoint: false, processes files individually
|
||||||
|
* 2. Multi-file tools: multiFileEndpoint: true, single API call with all files
|
||||||
|
* 3. Complex tools: customProcessor handles all processing logic
|
||||||
*/
|
*/
|
||||||
export interface ToolOperationConfig<TParams = void> {
|
export interface ToolOperationConfig<TParams = void> {
|
||||||
/** Operation identifier for tracking and logging */
|
/** Operation identifier for tracking and logging */
|
||||||
operationType: string;
|
operationType: string;
|
||||||
|
|
||||||
/** API endpoint for the operation (can be string or function for dynamic endpoints) */
|
/**
|
||||||
|
* API endpoint for the operation. Can be static string or function for dynamic routing.
|
||||||
|
* Not used when customProcessor is provided.
|
||||||
|
*/
|
||||||
endpoint: string | ((params: TParams) => string);
|
endpoint: string | ((params: TParams) => string);
|
||||||
|
|
||||||
/** Builds FormData for API request - signature indicates single-file vs multi-file capability */
|
/**
|
||||||
|
* Builds FormData for API request. Signature determines processing approach:
|
||||||
|
* - (params, file: File) => FormData: Single-file processing
|
||||||
|
* - (params, files: File[]) => FormData: Multi-file processing
|
||||||
|
* Not used when customProcessor is provided.
|
||||||
|
*/
|
||||||
buildFormData: ((params: TParams, file: File) => FormData) | ((params: TParams, files: File[]) => FormData);
|
buildFormData: ((params: TParams, file: File) => FormData) | ((params: TParams, files: File[]) => FormData);
|
||||||
|
|
||||||
/** Prefix for processed filenames (e.g., 'compressed_', 'repaired_') */
|
/** Prefix added to processed filenames (e.g., 'compressed_', 'split_') */
|
||||||
filePrefix: string;
|
filePrefix: string;
|
||||||
|
|
||||||
/** How to handle API responses */
|
/**
|
||||||
|
* Whether this tool uses backends that accept MultipartFile[] arrays.
|
||||||
|
* - true: Single API call with all files (backend uses MultipartFile[])
|
||||||
|
* - false/undefined: Individual API calls per file (backend uses single MultipartFile)
|
||||||
|
* Ignored when customProcessor is provided.
|
||||||
|
*/
|
||||||
|
multiFileEndpoint?: boolean;
|
||||||
|
|
||||||
|
/** How to handle API responses (e.g., ZIP extraction, single file response) */
|
||||||
responseHandler?: ResponseHandler;
|
responseHandler?: ResponseHandler;
|
||||||
|
|
||||||
/** Process files individually or as a batch */
|
/**
|
||||||
singleFileMode?: boolean;
|
* Custom processing logic that completely bypasses standard file processing.
|
||||||
|
* When provided, tool handles all API calls, response processing, and file creation.
|
||||||
/** Custom processing logic that bypasses default file processing */
|
* Use for tools with complex routing logic or non-standard processing requirements.
|
||||||
|
*/
|
||||||
customProcessor?: (params: TParams, files: File[]) => Promise<File[]>;
|
customProcessor?: (params: TParams, files: File[]) => Promise<File[]>;
|
||||||
|
|
||||||
/** Validate parameters before execution */
|
/** Validate parameters before execution. Return validation errors if invalid. */
|
||||||
validateParams?: (params: TParams) => ValidationResult;
|
validateParams?: (params: TParams) => ValidationResult;
|
||||||
|
|
||||||
/** Extract user-friendly error messages */
|
/** Extract user-friendly error messages from API errors */
|
||||||
getErrorMessage?: (error: any) => string;
|
getErrorMessage?: (error: any) => string;
|
||||||
|
|
||||||
/** Request timeout in milliseconds */
|
|
||||||
timeout?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -78,8 +97,16 @@ export interface ToolOperationHook<TParams = void> {
|
|||||||
export { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
export { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared hook for tool operations with consistent error handling, progress tracking,
|
* Shared hook for tool operations providing consistent error handling, progress tracking,
|
||||||
* and FileContext integration. Eliminates boilerplate while maintaining flexibility.
|
* and FileContext integration. Eliminates boilerplate while maintaining flexibility.
|
||||||
|
*
|
||||||
|
* Supports three tool patterns:
|
||||||
|
* 1. Single-file tools: Set multiFileEndpoint: false, processes files individually
|
||||||
|
* 2. Multi-file tools: Set multiFileEndpoint: true, single API call with all files
|
||||||
|
* 3. Complex tools: Provide customProcessor for full control over processing logic
|
||||||
|
*
|
||||||
|
* @param config - Tool operation configuration
|
||||||
|
* @returns Hook interface with state and execution methods
|
||||||
*/
|
*/
|
||||||
export const useToolOperation = <TParams = void>(
|
export const useToolOperation = <TParams = void>(
|
||||||
config: ToolOperationConfig<TParams>
|
config: ToolOperationConfig<TParams>
|
||||||
@ -133,11 +160,8 @@ export const useToolOperation = <TParams = void>(
|
|||||||
actions.setStatus('Processing files...');
|
actions.setStatus('Processing files...');
|
||||||
processedFiles = await config.customProcessor(params, validFiles);
|
processedFiles = await config.customProcessor(params, validFiles);
|
||||||
} else {
|
} else {
|
||||||
// Detect if buildFormData signature is multi-file or single-file
|
// Use explicit multiFileEndpoint flag to determine processing approach
|
||||||
// Both have 2 params now, so check if second param expects an array
|
if (config.multiFileEndpoint) {
|
||||||
const isMultiFileFormData = /files|selectedFiles/.test(config.buildFormData.toString());
|
|
||||||
|
|
||||||
if (isMultiFileFormData) {
|
|
||||||
// Multi-file processing - single API call with all files
|
// Multi-file processing - single API call with all files
|
||||||
actions.setStatus('Processing files...');
|
actions.setStatus('Processing files...');
|
||||||
const formData = (config.buildFormData as (params: TParams, files: File[]) => FormData)(params, validFiles);
|
const formData = (config.buildFormData as (params: TParams, files: File[]) => FormData)(params, validFiles);
|
||||||
@ -164,8 +188,7 @@ export const useToolOperation = <TParams = void>(
|
|||||||
endpoint: config.endpoint,
|
endpoint: config.endpoint,
|
||||||
buildFormData: (file: File, params: TParams) => (config.buildFormData as (params: TParams, file: File) => FormData)(params, file),
|
buildFormData: (file: File, params: TParams) => (config.buildFormData as (params: TParams, file: File) => FormData)(params, file),
|
||||||
filePrefix: config.filePrefix,
|
filePrefix: config.filePrefix,
|
||||||
responseHandler: config.responseHandler,
|
responseHandler: config.responseHandler
|
||||||
timeout: config.timeout
|
|
||||||
};
|
};
|
||||||
processedFiles = await processFiles(
|
processedFiles = await processFiles(
|
||||||
params,
|
params,
|
||||||
|
@ -63,8 +63,9 @@ export const useSplitOperation = () => {
|
|||||||
return useToolOperation<SplitParameters>({
|
return useToolOperation<SplitParameters>({
|
||||||
operationType: 'split',
|
operationType: 'split',
|
||||||
endpoint: (params) => getEndpoint(params),
|
endpoint: (params) => getEndpoint(params),
|
||||||
buildFormData: buildFormData, // Clean multi-file signature: (params, selectedFiles) => FormData
|
buildFormData: buildFormData, // Multi-file signature: (params, selectedFiles) => FormData
|
||||||
filePrefix: 'split_',
|
filePrefix: 'split_',
|
||||||
|
multiFileEndpoint: true, // Single API call with all files
|
||||||
responseHandler: {
|
responseHandler: {
|
||||||
type: 'zip',
|
type: 'zip',
|
||||||
useZipExtractor: true
|
useZipExtractor: true
|
||||||
|
@ -28,8 +28,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
thumbnailQuality: 'medium',
|
thumbnailQuality: 'medium',
|
||||||
priorityPageCount: 10,
|
priorityPageCount: 10,
|
||||||
useWebWorker: false,
|
useWebWorker: false,
|
||||||
maxRetries: 3,
|
maxRetries: 3
|
||||||
timeoutMs: 300000 // 5 minutes
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private constructor() {}
|
private constructor() {}
|
||||||
@ -87,7 +86,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
estimatedTime: number
|
estimatedTime: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Create cancellation token
|
// Create cancellation token
|
||||||
const cancellationToken = ProcessingErrorHandler.createTimeoutController(config.timeoutMs);
|
const cancellationToken = new AbortController();
|
||||||
|
|
||||||
// Set initial state
|
// Set initial state
|
||||||
const state: ProcessingState = {
|
const state: ProcessingState = {
|
||||||
|
@ -69,7 +69,6 @@ export interface ProcessingConfig {
|
|||||||
priorityPageCount: number; // Number of priority pages to process first
|
priorityPageCount: number; // Number of priority pages to process first
|
||||||
useWebWorker: boolean;
|
useWebWorker: boolean;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
timeoutMs: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileAnalysis {
|
export interface FileAnalysis {
|
||||||
|
Loading…
Reference in New Issue
Block a user