fix(bookings,blog): Phase 1 correctifs test utilisateur
- Telechargement docs transporteur: proxy de streaming API (fin des URLs presignees MinIO injoignables depuis l'exterieur) - Documents: ajout de l'action "Supprimer" (endpoint DELETE deja existant) - Blog: suppression reelle de l'image de couverture (null explicite bout en bout) - Blog: le bouton Retour navigateur ferme l'editeur au lieu de quitter vers Logs - Paiement: bouton Retour revient au contexte precedent (router.back) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
71436cb9c4
commit
19b96517d2
@ -117,7 +117,7 @@ export class BlogController {
|
||||
slug: post.slug,
|
||||
excerpt: post.excerpt,
|
||||
content: post.content,
|
||||
coverImageUrl: post.coverImageUrl,
|
||||
coverImageUrl: post.coverImageUrl ?? undefined,
|
||||
category: post.category,
|
||||
tags: post.tags,
|
||||
authorName: post.authorName,
|
||||
|
||||
@ -1,4 +1,14 @@
|
||||
import { Controller, Get, Post, Param, Query, Body } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Query,
|
||||
Body,
|
||||
Res,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger';
|
||||
import { Public } from '../decorators/public.decorator';
|
||||
import { CsvBookingService } from '../services/csv-booking.service';
|
||||
@ -173,4 +183,78 @@ export class CsvBookingActionsController {
|
||||
async getBookingDocuments(@Param('token') token: string): Promise<CarrierDocumentsResponseDto> {
|
||||
return this.csvBookingService.getDocumentsForCarrier(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single booking document, streamed through the API (PUBLIC - token-based).
|
||||
*
|
||||
* Password-protected bookings must use POST with the password in the body.
|
||||
*
|
||||
* POST /api/v1/csv-booking-actions/documents/:token/:documentId/download
|
||||
*/
|
||||
@Public()
|
||||
@Post('documents/:token/:documentId/download')
|
||||
@ApiOperation({
|
||||
summary: 'Download a booking document with password (public)',
|
||||
description:
|
||||
'Streams a single booking document to the carrier. Applies the same access rules as the documents list (booking accepted + password when protected).',
|
||||
})
|
||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
||||
@ApiParam({ name: 'documentId', description: 'Document ID' })
|
||||
@ApiBody({ type: VerifyDocumentAccessDto })
|
||||
@ApiResponse({ status: 200, description: 'Document streamed successfully.' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid or missing password' })
|
||||
@ApiResponse({ status: 404, description: 'Booking or document not found' })
|
||||
async downloadBookingDocumentWithPassword(
|
||||
@Param('token') token: string,
|
||||
@Param('documentId') documentId: string,
|
||||
@Body() dto: VerifyDocumentAccessDto,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
): Promise<StreamableFile> {
|
||||
return this.streamDocument(token, documentId, dto?.password, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single booking document (PUBLIC - token-based) - Legacy without password.
|
||||
*
|
||||
* GET /api/v1/csv-booking-actions/documents/:token/:documentId/download
|
||||
*/
|
||||
@Public()
|
||||
@Get('documents/:token/:documentId/download')
|
||||
@ApiOperation({
|
||||
summary: 'Download a booking document (public) - Legacy',
|
||||
description:
|
||||
'Streams a single booking document for bookings without password protection. Protected bookings must use the POST variant.',
|
||||
})
|
||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
||||
@ApiParam({ name: 'documentId', description: 'Document ID' })
|
||||
@ApiResponse({ status: 200, description: 'Document streamed successfully.' })
|
||||
@ApiResponse({ status: 401, description: 'Password required for this booking' })
|
||||
@ApiResponse({ status: 404, description: 'Booking or document not found' })
|
||||
async downloadBookingDocument(
|
||||
@Param('token') token: string,
|
||||
@Param('documentId') documentId: string,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
): Promise<StreamableFile> {
|
||||
return this.streamDocument(token, documentId, undefined, res);
|
||||
}
|
||||
|
||||
private async streamDocument(
|
||||
token: string,
|
||||
documentId: string,
|
||||
password: string | undefined,
|
||||
res: Response
|
||||
): Promise<StreamableFile> {
|
||||
const { buffer, fileName, mimeType } = await this.csvBookingService.streamDocumentForCarrier(
|
||||
token,
|
||||
documentId,
|
||||
password
|
||||
);
|
||||
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(fileName)}"`
|
||||
);
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
MaxLength,
|
||||
MinLength,
|
||||
Matches,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { BlogPostStatus, type BlogPostCategory } from '@domain/entities/blog-post.entity';
|
||||
|
||||
@ -121,11 +122,15 @@ export class UpdateBlogPostDto {
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: 'Cover image URL. Pass null to remove the current cover image.',
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value) => value !== null)
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
coverImageUrl?: string;
|
||||
coverImageUrl?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ enum: CATEGORIES })
|
||||
@IsOptional()
|
||||
|
||||
@ -735,20 +735,17 @@ export class CsvBookingService {
|
||||
throw new NotFoundException('Réservation introuvable');
|
||||
}
|
||||
|
||||
// Generate signed URLs for all documents
|
||||
const documentsWithUrls = await Promise.all(
|
||||
booking.documents.map(async doc => {
|
||||
const signedUrl = await this.generateSignedUrlForDocument(doc.filePath);
|
||||
return {
|
||||
id: doc.id,
|
||||
type: doc.type,
|
||||
fileName: doc.fileName,
|
||||
mimeType: doc.mimeType,
|
||||
size: doc.size,
|
||||
downloadUrl: signedUrl,
|
||||
};
|
||||
})
|
||||
);
|
||||
// Build proxy download URLs. The file is streamed through the API rather than
|
||||
// served via a presigned storage URL, whose internal host is unreachable from
|
||||
// the carrier's browser. The frontend prepends the public API base URL.
|
||||
const documentsWithUrls = booking.documents.map(doc => ({
|
||||
id: doc.id,
|
||||
type: doc.type,
|
||||
fileName: doc.fileName,
|
||||
mimeType: doc.mimeType,
|
||||
size: doc.size,
|
||||
downloadUrl: `/api/v1/csv-booking-actions/documents/${token}/${doc.id}/download`,
|
||||
}));
|
||||
|
||||
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
|
||||
|
||||
@ -795,12 +792,10 @@ export class CsvBookingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate signed URL for a document file path
|
||||
* Extract the storage object key from a stored document file path.
|
||||
* Handles raw keys, "bucket/key" paths and full URLs.
|
||||
*/
|
||||
private async generateSignedUrlForDocument(filePath: string): Promise<string> {
|
||||
const bucket = 'xpeditis-documents';
|
||||
|
||||
// Extract key from the file path
|
||||
private extractDocumentKey(filePath: string): string {
|
||||
let key = filePath;
|
||||
if (filePath.includes('xpeditis-documents/')) {
|
||||
key = filePath.split('xpeditis-documents/')[1];
|
||||
@ -811,10 +806,65 @@ export class CsvBookingService {
|
||||
key = key.replace('xpeditis-documents/', '');
|
||||
}
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
// Generate signed URL with 1 hour expiration
|
||||
const signedUrl = await this.storageAdapter.getSignedUrl({ bucket, key }, 3600);
|
||||
return signedUrl;
|
||||
/**
|
||||
* Stream a booking document to the carrier through the API (public, token-based).
|
||||
*
|
||||
* Downloads the file from storage and returns it as a buffer so the API can
|
||||
* proxy it to the carrier's browser. This avoids handing out presigned storage
|
||||
* URLs whose host (internal MinIO/S3 endpoint) is not reachable from outside.
|
||||
*
|
||||
* Applies the same access rules as {@link getDocumentsForCarrier}: the booking
|
||||
* must be ACCEPTED and, when protected, the correct password must be supplied.
|
||||
*/
|
||||
async streamDocumentForCarrier(
|
||||
token: string,
|
||||
documentId: string,
|
||||
password?: string
|
||||
): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> {
|
||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||||
where: { confirmationToken: token },
|
||||
});
|
||||
|
||||
if (!ormBooking) {
|
||||
throw new NotFoundException('Réservation introuvable');
|
||||
}
|
||||
|
||||
if (ormBooking.status !== 'ACCEPTED') {
|
||||
throw new BadRequestException("Cette réservation n'a pas encore été acceptée");
|
||||
}
|
||||
|
||||
if (ormBooking.passwordHash) {
|
||||
if (!password) {
|
||||
throw new UnauthorizedException('Mot de passe requis pour accéder aux documents');
|
||||
}
|
||||
const isPasswordValid = await argon2.verify(ormBooking.passwordHash, password);
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('Mot de passe incorrect');
|
||||
}
|
||||
}
|
||||
|
||||
const booking = await this.csvBookingRepository.findByToken(token);
|
||||
if (!booking) {
|
||||
throw new NotFoundException('Réservation introuvable');
|
||||
}
|
||||
|
||||
const document = booking.documents.find(doc => doc.id === documentId);
|
||||
if (!document) {
|
||||
throw new NotFoundException('Document introuvable');
|
||||
}
|
||||
|
||||
const bucket = 'xpeditis-documents';
|
||||
const key = this.extractDocumentKey(document.filePath);
|
||||
const buffer = await this.storageAdapter.download({ bucket, key });
|
||||
|
||||
return {
|
||||
buffer,
|
||||
fileName: document.fileName,
|
||||
mimeType: document.mimeType || 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -13,7 +13,7 @@ interface BlogPostProps {
|
||||
slug: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
coverImageUrl?: string;
|
||||
coverImageUrl?: string | null;
|
||||
category: BlogPostCategory;
|
||||
tags: string[];
|
||||
authorName: string;
|
||||
@ -64,7 +64,7 @@ export class BlogPost {
|
||||
get content(): string {
|
||||
return this.props.content;
|
||||
}
|
||||
get coverImageUrl(): string | undefined {
|
||||
get coverImageUrl(): string | null | undefined {
|
||||
return this.props.coverImageUrl;
|
||||
}
|
||||
get category(): BlogPostCategory {
|
||||
|
||||
@ -20,7 +20,7 @@ export class BlogPostOrmEntity {
|
||||
content: string;
|
||||
|
||||
@Column('varchar', { length: 500, nullable: true })
|
||||
cover_image_url?: string;
|
||||
cover_image_url?: string | null;
|
||||
|
||||
@Column('varchar', { length: 50 })
|
||||
category: string;
|
||||
|
||||
@ -29,6 +29,7 @@ import {
|
||||
Clock,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
|
||||
@ -181,7 +182,9 @@ export default function AdminBlogPage() {
|
||||
slug: formData.slug,
|
||||
excerpt: formData.excerpt,
|
||||
content: formData.content,
|
||||
coverImageUrl: formData.coverImageUrl || undefined,
|
||||
// Send explicit null (not undefined) when the cover was removed, so the
|
||||
// backend clears the column instead of leaving the previous value.
|
||||
coverImageUrl: formData.coverImageUrl?.trim() ? formData.coverImageUrl : null,
|
||||
category: formData.category,
|
||||
tags,
|
||||
authorName: formData.authorName,
|
||||
@ -318,7 +321,9 @@ export default function AdminBlogPage() {
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
// Pure UI reset — does not touch browser history. Used by the popstate
|
||||
// handler (the browser already popped our entry at that point).
|
||||
const resetEditorState = () => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedPost(null);
|
||||
@ -328,6 +333,28 @@ export default function AdminBlogPage() {
|
||||
setSeoOpen(false);
|
||||
};
|
||||
|
||||
// Explicit close (X / Retour / after save): reset UI and pop the history
|
||||
// entry we pushed when opening, so the browser Back button stays on /admin/blog.
|
||||
const closeModal = () => {
|
||||
resetEditorState();
|
||||
if (typeof window !== 'undefined' && window.history.state?.xpBlogEditor) {
|
||||
window.history.back();
|
||||
}
|
||||
};
|
||||
|
||||
// Keep the browser Back button inside the blog editor: pushing a history entry
|
||||
// when the editor opens means Back closes the editor instead of navigating away
|
||||
// (e.g. to the System Logs tab).
|
||||
useEffect(() => {
|
||||
const editorOpen = showCreateModal || showEditModal;
|
||||
if (!editorOpen) return;
|
||||
window.history.pushState({ xpBlogEditor: true }, '');
|
||||
const handlePop = () => resetEditorState();
|
||||
window.addEventListener('popstate', handlePop);
|
||||
return () => window.removeEventListener('popstate', handlePop);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showCreateModal, showEditModal]);
|
||||
|
||||
const handleTitleChange = (title: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
@ -535,9 +562,19 @@ export default function AdminBlogPage() {
|
||||
<div className="fixed inset-0 z-50 bg-gray-50 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{showCreateModal ? 'Nouvel article' : `Modifier — ${selectedPost?.title}`}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg px-3 py-1.5 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Retour
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{showCreateModal ? 'Nouvel article' : `Modifier — ${selectedPost?.title}`}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{showEditModal && (
|
||||
<select
|
||||
|
||||
@ -277,7 +277,30 @@ export default function CarrierDocumentsPage() {
|
||||
setDownloading(doc.id);
|
||||
|
||||
try {
|
||||
window.open(doc.downloadUrl, '_blank');
|
||||
// Stream the document through the API instead of relying on a presigned
|
||||
// storage URL (whose internal host is unreachable from outside).
|
||||
const downloadUrl = `${apiUrl}/api/v1/csv-booking-actions/documents/${token}/${doc.id}/download`;
|
||||
const usePassword = !!requirements?.requiresPassword && !!password;
|
||||
|
||||
const response = await fetch(downloadUrl, {
|
||||
method: usePassword ? 'POST' : 'GET',
|
||||
headers: usePassword ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: usePassword ? JSON.stringify({ password }) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = doc.fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(objectUrl);
|
||||
} catch (err) {
|
||||
console.error('Error downloading document:', err);
|
||||
alert(t('downloadError'));
|
||||
|
||||
@ -212,13 +212,14 @@ export default function PayCommissionPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Back button */}
|
||||
{/* Back button — returns to the previous context (list or booking detail)
|
||||
instead of always jumping to the global list. */}
|
||||
<button
|
||||
onClick={() => router.push('/dashboard/bookings')}
|
||||
onClick={() => router.back()}
|
||||
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Retour aux bookings
|
||||
Retour
|
||||
</button>
|
||||
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement du forfait booking</h1>
|
||||
|
||||
@ -3,7 +3,14 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
||||
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
||||
import {
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
FileEdit,
|
||||
FileSpreadsheet,
|
||||
Paperclip,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import ExportButton from '@/components/ExportButton';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
@ -56,6 +63,10 @@ export default function UserDocumentsPage() {
|
||||
const [replaceFile, setReplaceFile] = useState<File | null>(null);
|
||||
const replaceFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [documentToDelete, setDocumentToDelete] = useState<DocumentWithBooking | null>(null);
|
||||
const [deletingFile, setDeletingFile] = useState(false);
|
||||
|
||||
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
||||
|
||||
const getQuoteNumber = (booking: CsvBookingResponse): string => {
|
||||
@ -355,6 +366,43 @@ export default function UserDocumentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (doc: DocumentWithBooking) => {
|
||||
setOpenDropdownId(null);
|
||||
setDocumentToDelete(doc);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleCloseDeleteModal = () => {
|
||||
setShowDeleteModal(false);
|
||||
setDocumentToDelete(null);
|
||||
};
|
||||
|
||||
const handleDeleteDocument = async () => {
|
||||
if (!documentToDelete) return;
|
||||
|
||||
setDeletingFile(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToDelete.bookingId}/documents/${documentToDelete.id}`,
|
||||
{ method: 'DELETE', credentials: 'include' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || t('deleteDocument.errorMessage'));
|
||||
}
|
||||
|
||||
alert(t('deleteDocument.successMessage'));
|
||||
handleCloseDeleteModal();
|
||||
fetchBookingsAndDocuments();
|
||||
} catch (err) {
|
||||
console.error('Error deleting document:', err);
|
||||
alert(`${t('deleteDocument.errorMessage')}: ${err instanceof Error ? err.message : ''}`);
|
||||
} finally {
|
||||
setDeletingFile(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
@ -611,6 +659,13 @@ export default function UserDocumentsPage() {
|
||||
</svg>
|
||||
{t('actions.replace')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteClick(doc)}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-3" />
|
||||
{t('actions.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -1049,6 +1104,50 @@ export default function UserDocumentsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Document Modal */}
|
||||
{showDeleteModal && documentToDelete && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen px-4 py-8">
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
onClick={handleCloseDeleteModal}
|
||||
/>
|
||||
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-md p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<Trash2 className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{t('deleteDocument.title')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
{t('deleteDocument.message', { fileName: documentToDelete.fileName })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseDeleteModal}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
{t('deleteDocument.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteDocument}
|
||||
disabled={deletingFile}
|
||||
className="px-6 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deletingFile ? t('deleteDocument.deleting') : t('deleteDocument.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1524,7 +1524,8 @@
|
||||
},
|
||||
"actions": {
|
||||
"download": "Download",
|
||||
"replace": "Replace"
|
||||
"replace": "Replace",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"export": {
|
||||
"fileName": "File name",
|
||||
@ -1566,6 +1567,15 @@
|
||||
"errorMessage": "Error replacing document",
|
||||
"noFileError": "Please select a replacement file"
|
||||
},
|
||||
"deleteDocument": {
|
||||
"title": "Delete document",
|
||||
"message": "Are you sure you want to delete \"{fileName}\"? This action cannot be undone.",
|
||||
"confirm": "Delete",
|
||||
"deleting": "Deleting...",
|
||||
"cancel": "Cancel",
|
||||
"successMessage": "Document deleted successfully!",
|
||||
"errorMessage": "Error deleting document"
|
||||
},
|
||||
"downloadError": "Error downloading document"
|
||||
},
|
||||
"wikiPages": {
|
||||
|
||||
@ -1524,7 +1524,8 @@
|
||||
},
|
||||
"actions": {
|
||||
"download": "Télécharger",
|
||||
"replace": "Remplacer"
|
||||
"replace": "Remplacer",
|
||||
"delete": "Supprimer"
|
||||
},
|
||||
"export": {
|
||||
"fileName": "Nom du fichier",
|
||||
@ -1566,6 +1567,15 @@
|
||||
"errorMessage": "Erreur lors du remplacement du document",
|
||||
"noFileError": "Veuillez sélectionner un fichier de remplacement"
|
||||
},
|
||||
"deleteDocument": {
|
||||
"title": "Supprimer le document",
|
||||
"message": "Êtes-vous sûr de vouloir supprimer « {fileName} » ? Cette action est irréversible.",
|
||||
"confirm": "Supprimer",
|
||||
"deleting": "Suppression en cours...",
|
||||
"cancel": "Annuler",
|
||||
"successMessage": "Document supprimé avec succès!",
|
||||
"errorMessage": "Erreur lors de la suppression du document"
|
||||
},
|
||||
"downloadError": "Erreur lors du téléchargement du document"
|
||||
},
|
||||
"wikiPages": {
|
||||
|
||||
@ -210,7 +210,7 @@ export interface CreateBlogPostRequest extends BlogPostSeoFields {
|
||||
slug: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
coverImageUrl?: string;
|
||||
coverImageUrl?: string | null;
|
||||
category: BlogPostCategory;
|
||||
tags?: string[];
|
||||
authorName: string;
|
||||
@ -221,7 +221,7 @@ export interface UpdateBlogPostRequest extends BlogPostSeoFields {
|
||||
slug?: string;
|
||||
excerpt?: string;
|
||||
content?: string;
|
||||
coverImageUrl?: string;
|
||||
coverImageUrl?: string | null;
|
||||
category?: BlogPostCategory;
|
||||
tags?: string[];
|
||||
authorName?: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user