mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-23 06:11:58 +00:00
feat: enhance reverse share upload functionality with improved localization and error handling
- Updated localization files to include new strings for the reverse share upload process, enhancing user experience in multiple languages. - Implemented a new layout for reverse share uploads, integrating file upload sections and status messages for better feedback. - Added error handling for various upload scenarios, including password protection, link expiration, and file validation. - Refactored components to utilize hooks for managing upload state and responses, improving code organization and maintainability. - Introduced a new password modal for handling protected links, enhancing security during file uploads. - Enhanced the user interface with dynamic status messages and visual feedback during the upload process.
This commit is contained in:
@@ -1183,7 +1183,7 @@
|
||||
},
|
||||
"allowedFileTypes": {
|
||||
"label": "Tipos de Arquivo Permitidos",
|
||||
"placeholder": "Ex: .pdf,.jpg,.png,.docx",
|
||||
"placeholder": "Ex: pdf, jpg, png, docx",
|
||||
"description": "Opcional. Especifique as extensões permitidas separadas por vírgula."
|
||||
},
|
||||
"pageLayout": {
|
||||
@@ -1227,6 +1227,89 @@
|
||||
"confirmButton": "Excluir Link",
|
||||
"cancelButton": "Cancelar",
|
||||
"deleting": "Excluindo..."
|
||||
},
|
||||
"upload": {
|
||||
"metadata": {
|
||||
"title": "Enviar Arquivos - Palmr",
|
||||
"description": "Envie arquivos através do link compartilhado"
|
||||
},
|
||||
"layout": {
|
||||
"defaultTitle": "Enviar Arquivos",
|
||||
"importantInfo": "Informações importantes:",
|
||||
"maxFiles": "Máximo de {count} arquivo(s)",
|
||||
"maxFileSize": "Tamanho máximo por arquivo: {size}MB",
|
||||
"allowedTypes": "Tipos permitidos: {types}",
|
||||
"loading": "Carregando..."
|
||||
},
|
||||
"password": {
|
||||
"title": "Link Protegido",
|
||||
"description": "Este link está protegido por senha. Digite a senha para continuar.",
|
||||
"label": "Senha",
|
||||
"placeholder": "Digite a senha",
|
||||
"cancel": "Cancelar",
|
||||
"submit": "Continuar",
|
||||
"verifying": "Verificando..."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Falha ao carregar informações. Tente novamente.",
|
||||
"passwordIncorrect": "Senha incorreta. Tente novamente.",
|
||||
"linkNotFound": "Link não encontrado ou expirado.",
|
||||
"linkInactive": "Este link está inativo.",
|
||||
"linkExpired": "Este link expirou.",
|
||||
"uploadFailed": "Erro ao enviar arquivo",
|
||||
"fileTooLarge": "Arquivo muito grande. Tamanho máximo: {maxSize}",
|
||||
"fileTypeNotAllowed": "Tipo de arquivo não permitido. Tipos aceitos: {allowedTypes}",
|
||||
"maxFilesExceeded": "Máximo de {maxFiles} arquivos permitidos",
|
||||
"selectAtLeastOneFile": "Selecione pelo menos um arquivo",
|
||||
"provideNameOrEmail": "Informe seu nome ou e-mail"
|
||||
},
|
||||
"fileDropzone": {
|
||||
"dragActive": "Solte os arquivos aqui",
|
||||
"dragInactive": "Arraste arquivos aqui ou clique para selecionar",
|
||||
"acceptedTypes": "Tipos aceitos: {types}",
|
||||
"maxFileSize": "Tamanho máximo: {size}",
|
||||
"maxFiles": "Máximo de {count} arquivos",
|
||||
"remainingFiles": "Restam {remaining} de {max} arquivos"
|
||||
},
|
||||
"fileList": {
|
||||
"title": "Arquivos selecionados:",
|
||||
"statusUploaded": "Enviado",
|
||||
"statusError": "Erro"
|
||||
},
|
||||
"form": {
|
||||
"nameLabel": "Nome",
|
||||
"namePlaceholder": "Seu nome",
|
||||
"emailLabel": "E-mail",
|
||||
"emailPlaceholder": "seu@email.com",
|
||||
"descriptionLabel": "Descrição (opcional)",
|
||||
"descriptionPlaceholder": "Adicione uma descrição aos arquivos...",
|
||||
"uploadButton": "Enviar {count} arquivo(s)",
|
||||
"uploading": "Enviando..."
|
||||
},
|
||||
"success": {
|
||||
"title": "Arquivos enviados com sucesso! 🎉",
|
||||
"description": "Você pode fechar esta página.",
|
||||
"countMessage": "{count} arquivo(s) enviado(s) com sucesso!"
|
||||
},
|
||||
"maxFilesReached": {
|
||||
"title": "Limite de arquivos atingido",
|
||||
"description": "Este link já recebeu o número máximo de {maxFiles} arquivo(s) permitido(s).",
|
||||
"contactOwner": "Se houve algum erro ou você precisa enviar mais arquivos, entre em contato com o proprietário do link."
|
||||
},
|
||||
"linkInactive": {
|
||||
"title": "Link inativo",
|
||||
"description": "Este link de recebimento está temporariamente inativo.",
|
||||
"contactOwner": "Entre em contato com o proprietário do link para mais informações."
|
||||
},
|
||||
"linkNotFound": {
|
||||
"title": "Link não encontrado",
|
||||
"description": "Este link pode ter sido removido ou nunca existiu."
|
||||
},
|
||||
"linkExpired": {
|
||||
"title": "Link expirado",
|
||||
"description": "Este link de recebimento expirou e não está mais aceitando arquivos.",
|
||||
"contactOwner": "Entre em contato com o proprietário do link se precisar enviar arquivos."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,24 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { IconAlertTriangle, IconCheck, IconClock, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { LanguageSwitcher } from "@/components/general/language-switcher";
|
||||
import { ModeToggle } from "@/components/general/mode-toggle";
|
||||
import { DefaultFooter } from "@/components/ui/default-footer";
|
||||
import { useAppInfo } from "@/contexts/app-info-context";
|
||||
import type { GetReverseShareForUploadResult } from "@/http/endpoints/reverse-shares/types";
|
||||
import type { DefaultLayoutProps } from "../types";
|
||||
import { FileUploadSection } from "./file-upload-section";
|
||||
import { StatusMessage } from "./shared/status-message";
|
||||
|
||||
type ReverseShareInfo = GetReverseShareForUploadResult["data"]["reverseShare"];
|
||||
|
||||
interface DefaultLayoutProps {
|
||||
reverseShare: ReverseShareInfo;
|
||||
password: string;
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export function DefaultLayout({ reverseShare, password, alias }: DefaultLayoutProps) {
|
||||
export function DefaultLayout({
|
||||
reverseShare,
|
||||
password,
|
||||
alias,
|
||||
isMaxFilesReached,
|
||||
hasUploadedSuccessfully,
|
||||
onUploadSuccess,
|
||||
isLinkInactive,
|
||||
isLinkNotFound,
|
||||
isLinkExpired,
|
||||
}: DefaultLayoutProps) {
|
||||
const { appName, appLogo } = useAppInfo();
|
||||
const t = useTranslations();
|
||||
|
||||
const getUploadStatus = () => {
|
||||
if (hasUploadedSuccessfully) {
|
||||
return {
|
||||
component: (
|
||||
<StatusMessage
|
||||
icon={IconCheck}
|
||||
title={t("reverseShares.upload.success.title")}
|
||||
description={t("reverseShares.upload.success.description")}
|
||||
variant="success"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (isLinkInactive) {
|
||||
return {
|
||||
component: (
|
||||
<StatusMessage
|
||||
icon={IconAlertTriangle}
|
||||
title={t("reverseShares.upload.linkInactive.title")}
|
||||
description={t("reverseShares.upload.linkInactive.description")}
|
||||
additionalText={t("reverseShares.upload.linkInactive.contactOwner")}
|
||||
variant="error"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (isLinkNotFound || !reverseShare) {
|
||||
return {
|
||||
component: (
|
||||
<StatusMessage
|
||||
icon={IconAlertTriangle}
|
||||
title={t("reverseShares.upload.linkNotFound.title")}
|
||||
description={t("reverseShares.upload.linkNotFound.description")}
|
||||
variant="neutral"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (isLinkExpired) {
|
||||
return {
|
||||
component: (
|
||||
<StatusMessage
|
||||
icon={IconClock}
|
||||
title={t("reverseShares.upload.linkExpired.title")}
|
||||
description={t("reverseShares.upload.linkExpired.description")}
|
||||
additionalText={t("reverseShares.upload.linkExpired.contactOwner")}
|
||||
variant="info"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (isMaxFilesReached) {
|
||||
return {
|
||||
component: (
|
||||
<StatusMessage
|
||||
icon={IconInfoCircle}
|
||||
title={t("reverseShares.upload.maxFilesReached.title")}
|
||||
description={t("reverseShares.upload.maxFilesReached.description", {
|
||||
maxFiles: reverseShare?.maxFiles || 0,
|
||||
})}
|
||||
additionalText={t("reverseShares.upload.maxFilesReached.contactOwner")}
|
||||
variant="warning"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
component: (
|
||||
<FileUploadSection
|
||||
reverseShare={reverseShare}
|
||||
password={password}
|
||||
alias={alias}
|
||||
onUploadSuccess={onUploadSuccess}
|
||||
/>
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const showUploadLimits =
|
||||
!hasUploadedSuccessfully &&
|
||||
!isMaxFilesReached &&
|
||||
!isLinkInactive &&
|
||||
!isLinkNotFound &&
|
||||
!isLinkExpired &&
|
||||
reverseShare &&
|
||||
(reverseShare.maxFiles || reverseShare.maxFileSize || reverseShare.allowedFileTypes);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
@@ -42,9 +140,9 @@ export function DefaultLayout({ reverseShare, password, alias }: DefaultLayoutPr
|
||||
{/* Header da página */}
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-foreground">
|
||||
{reverseShare.name || "Enviar Arquivos"}
|
||||
{reverseShare?.name || t("reverseShares.upload.layout.defaultTitle")}
|
||||
</h1>
|
||||
{reverseShare.description && (
|
||||
{reverseShare?.description && (
|
||||
<p className="text-lg md:text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
|
||||
{reverseShare.description}
|
||||
</p>
|
||||
@@ -53,17 +151,23 @@ export function DefaultLayout({ reverseShare, password, alias }: DefaultLayoutPr
|
||||
|
||||
{/* Seção de upload */}
|
||||
<div className="bg-card rounded-xl shadow-sm border border-border p-6 md:p-8 lg:p-10">
|
||||
<FileUploadSection reverseShare={reverseShare} password={password} alias={alias} />
|
||||
{getUploadStatus().component}
|
||||
</div>
|
||||
|
||||
{/* Informações adicionais (se houver limites) */}
|
||||
{(reverseShare.maxFiles || reverseShare.maxFileSize || reverseShare.allowedFileTypes) && (
|
||||
{/* Informações adicionais */}
|
||||
{showUploadLimits && (
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-2">
|
||||
<h3 className="text-sm font-medium text-foreground">Informações importantes:</h3>
|
||||
<h3 className="text-sm font-medium text-foreground">{t("reverseShares.upload.layout.importantInfo")}</h3>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
{reverseShare.maxFiles && <p>• Máximo de {reverseShare.maxFiles} arquivo(s)</p>}
|
||||
{reverseShare.maxFileSize && <p>• Tamanho máximo por arquivo: {reverseShare.maxFileSize}MB</p>}
|
||||
{reverseShare.allowedFileTypes && <p>• Tipos permitidos: {reverseShare.allowedFileTypes}</p>}
|
||||
{reverseShare?.maxFiles && (
|
||||
<p>• {t("reverseShares.upload.layout.maxFiles", { count: reverseShare.maxFiles })}</p>
|
||||
)}
|
||||
{reverseShare?.maxFileSize && (
|
||||
<p>• {t("reverseShares.upload.layout.maxFileSize", { size: reverseShare.maxFileSize })}</p>
|
||||
)}
|
||||
{reverseShare?.allowedFileTypes && (
|
||||
<p>• {t("allowedTypes", { types: reverseShare.allowedFileTypes })}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { IconCheck, IconFile, IconMail, IconUpload, IconUser, IconX } from "@tabler/icons-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -12,79 +13,86 @@ import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getPresignedUrlForUploadByAlias, registerFileUploadByAlias } from "@/http/endpoints";
|
||||
import type { GetReverseShareForUploadResult } from "@/http/endpoints/reverse-shares/types";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
import { FILE_STATUS, UPLOAD_CONFIG, UPLOAD_PROGRESS } from "../constants";
|
||||
import { FileUploadSectionProps, FileWithProgress } from "../types";
|
||||
|
||||
type ReverseShareInfo = GetReverseShareForUploadResult["data"]["reverseShare"];
|
||||
|
||||
interface FileUploadSectionProps {
|
||||
reverseShare: ReverseShareInfo;
|
||||
password: string;
|
||||
alias: string;
|
||||
}
|
||||
|
||||
interface FileWithProgress {
|
||||
file: File;
|
||||
progress: number;
|
||||
status: "pending" | "uploading" | "success" | "error";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function FileUploadSection({ reverseShare, password, alias }: FileUploadSectionProps) {
|
||||
export function FileUploadSection({ reverseShare, password, alias, onUploadSuccess }: FileUploadSectionProps) {
|
||||
const [files, setFiles] = useState<FileWithProgress[]>([]);
|
||||
const [uploaderName, setUploaderName] = useState("");
|
||||
const [uploaderEmail, setUploaderEmail] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
// Check file size
|
||||
if (reverseShare.maxFileSize) {
|
||||
const maxSize = reverseShare.maxFileSize;
|
||||
if (file.size > maxSize) {
|
||||
return `Arquivo muito grande. Tamanho máximo: ${formatFileSize(maxSize)}`;
|
||||
}
|
||||
}
|
||||
const t = useTranslations();
|
||||
|
||||
// Check file type
|
||||
if (reverseShare.allowedFileTypes) {
|
||||
const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase());
|
||||
const fileExtension = file.name.split(".").pop()?.toLowerCase();
|
||||
if (fileExtension && !allowedTypes.includes(fileExtension)) {
|
||||
return `Tipo de arquivo não permitido. Tipos aceitos: ${reverseShare.allowedFileTypes}`;
|
||||
}
|
||||
}
|
||||
const validateFileSize = (file: File): string | null => {
|
||||
if (!reverseShare.maxFileSize) return null;
|
||||
|
||||
// Check file count
|
||||
if (reverseShare.maxFiles) {
|
||||
const totalFiles = files.length + 1 + reverseShare.currentFileCount;
|
||||
if (totalFiles > reverseShare.maxFiles) {
|
||||
return `Máximo de ${reverseShare.maxFiles} arquivos permitidos`;
|
||||
}
|
||||
if (file.size > reverseShare.maxFileSize) {
|
||||
return t("reverseShares.upload.errors.fileTooLarge", {
|
||||
maxSize: formatFileSize(reverseShare.maxFileSize),
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateFileType = (file: File): string | null => {
|
||||
if (!reverseShare.allowedFileTypes) return null;
|
||||
|
||||
const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase());
|
||||
|
||||
const fileExtension = file.name.split(".").pop()?.toLowerCase();
|
||||
|
||||
if (fileExtension && !allowedTypes.includes(fileExtension)) {
|
||||
return t("reverseShares.upload.errors.fileTypeNotAllowed", {
|
||||
allowedTypes: reverseShare.allowedFileTypes,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateFileCount = (): string | null => {
|
||||
if (!reverseShare.maxFiles) return null;
|
||||
|
||||
const totalFiles = files.length + 1 + reverseShare.currentFileCount;
|
||||
if (totalFiles > reverseShare.maxFiles) {
|
||||
return t("reverseShares.upload.errors.maxFilesExceeded", {
|
||||
maxFiles: reverseShare.maxFiles,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
return validateFileSize(file) || validateFileType(file) || validateFileCount();
|
||||
};
|
||||
|
||||
const createFileWithProgress = (file: File): FileWithProgress => ({
|
||||
file,
|
||||
progress: UPLOAD_PROGRESS.INITIAL,
|
||||
status: FILE_STATUS.PENDING,
|
||||
});
|
||||
|
||||
const processAcceptedFiles = (acceptedFiles: File[]): FileWithProgress[] => {
|
||||
const validFiles: FileWithProgress[] = [];
|
||||
|
||||
for (const file of acceptedFiles) {
|
||||
const validationError = validateFile(file);
|
||||
if (validationError) {
|
||||
toast.error(validationError);
|
||||
continue;
|
||||
}
|
||||
validFiles.push(createFileWithProgress(file));
|
||||
}
|
||||
|
||||
return validFiles;
|
||||
};
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
const newFiles: FileWithProgress[] = [];
|
||||
|
||||
for (const file of acceptedFiles) {
|
||||
const error = validateFile(file);
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
continue;
|
||||
}
|
||||
|
||||
newFiles.push({
|
||||
file,
|
||||
progress: 0,
|
||||
status: "pending",
|
||||
});
|
||||
}
|
||||
|
||||
setFiles((prev) => [...prev, ...newFiles]);
|
||||
const newFiles = processAcceptedFiles(acceptedFiles);
|
||||
setFiles((previousFiles) => [...previousFiles, ...newFiles]);
|
||||
},
|
||||
[files, reverseShare]
|
||||
);
|
||||
@@ -96,98 +104,132 @@ export function FileUploadSection({ reverseShare, password, alias }: FileUploadS
|
||||
});
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
setFiles((previousFiles) => previousFiles.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateFileStatus = (index: number, updates: Partial<FileWithProgress>) => {
|
||||
setFiles((previousFiles) => previousFiles.map((file, i) => (i === index ? { ...file, ...updates } : file)));
|
||||
};
|
||||
|
||||
const generateObjectName = (fileName: string): string => {
|
||||
const timestamp = Date.now();
|
||||
return `reverse-shares/${alias}/${timestamp}-${fileName}`;
|
||||
};
|
||||
|
||||
const getFileExtension = (fileName: string): string => {
|
||||
return fileName.split(".").pop() || "";
|
||||
};
|
||||
|
||||
const uploadFileToStorage = async (file: File, presignedUrl: string): Promise<void> => {
|
||||
const response = await fetch(presignedUrl, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to upload file to storage");
|
||||
}
|
||||
};
|
||||
|
||||
const registerUploadedFile = async (file: File, objectName: string): Promise<void> => {
|
||||
const fileExtension = getFileExtension(file.name);
|
||||
|
||||
await registerFileUploadByAlias(
|
||||
alias,
|
||||
{
|
||||
name: file.name,
|
||||
description: description || undefined,
|
||||
extension: fileExtension,
|
||||
size: file.size,
|
||||
objectName,
|
||||
uploaderEmail: uploaderEmail || undefined,
|
||||
uploaderName: uploaderName || undefined,
|
||||
},
|
||||
password ? { password } : undefined
|
||||
);
|
||||
};
|
||||
|
||||
const uploadFile = async (fileWithProgress: FileWithProgress, index: number): Promise<void> => {
|
||||
const { file } = fileWithProgress;
|
||||
|
||||
try {
|
||||
// Update status to uploading
|
||||
setFiles((prev) => prev.map((f, i) => (i === index ? { ...f, status: "uploading" as const, progress: 0 } : f)));
|
||||
// Start upload
|
||||
updateFileStatus(index, {
|
||||
status: FILE_STATUS.UPLOADING,
|
||||
progress: UPLOAD_PROGRESS.INITIAL,
|
||||
});
|
||||
|
||||
// Generate object name for the file
|
||||
const timestamp = Date.now();
|
||||
const fileExtension = file.name.split(".").pop() || "";
|
||||
const objectName = `reverse-shares/${alias}/${timestamp}-${file.name}`;
|
||||
|
||||
// Get presigned URL
|
||||
// Generate object name and get presigned URL
|
||||
const objectName = generateObjectName(file.name);
|
||||
const presignedResponse = await getPresignedUrlForUploadByAlias(
|
||||
alias,
|
||||
{ objectName },
|
||||
password ? { password } : undefined
|
||||
);
|
||||
|
||||
const { url } = presignedResponse.data;
|
||||
// Upload to storage
|
||||
await uploadFileToStorage(file, presignedResponse.data.url);
|
||||
|
||||
// Upload file to presigned URL
|
||||
const uploadResponse = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error("Failed to upload file to storage");
|
||||
}
|
||||
|
||||
// Update progress to 100%
|
||||
setFiles((prev) => prev.map((f, i) => (i === index ? { ...f, progress: 100 } : f)));
|
||||
// Update progress
|
||||
updateFileStatus(index, { progress: UPLOAD_PROGRESS.COMPLETE });
|
||||
|
||||
// Register file upload
|
||||
await registerFileUploadByAlias(
|
||||
alias,
|
||||
{
|
||||
name: file.name,
|
||||
description: description || undefined,
|
||||
extension: fileExtension,
|
||||
size: file.size,
|
||||
objectName,
|
||||
uploaderEmail: uploaderEmail || undefined,
|
||||
uploaderName: uploaderName || undefined,
|
||||
},
|
||||
password ? { password } : undefined
|
||||
);
|
||||
await registerUploadedFile(file, objectName);
|
||||
|
||||
// Update status to success
|
||||
setFiles((prev) => prev.map((f, i) => (i === index ? { ...f, status: "success" as const } : f)));
|
||||
// Mark as successful
|
||||
updateFileStatus(index, { status: FILE_STATUS.SUCCESS });
|
||||
} catch (error: any) {
|
||||
console.error("Upload error:", error);
|
||||
const errorMessage = error.response?.data?.error || "Erro ao enviar arquivo";
|
||||
const errorMessage = error.response?.data?.error || t("reverseShares.upload.errors.uploadFailed");
|
||||
|
||||
setFiles((prev) =>
|
||||
prev.map((f, i) => (i === index ? { ...f, status: "error" as const, error: errorMessage } : f))
|
||||
);
|
||||
updateFileStatus(index, {
|
||||
status: FILE_STATUS.ERROR,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
const validateUploadRequirements = (): boolean => {
|
||||
if (files.length === 0) {
|
||||
toast.error("Selecione pelo menos um arquivo");
|
||||
return;
|
||||
toast.error(t("reverseShares.upload.errors.selectAtLeastOneFile"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!uploaderName && !uploaderEmail) {
|
||||
toast.error("Informe seu nome ou e-mail");
|
||||
return;
|
||||
if (!uploaderName.trim() && !uploaderEmail.trim()) {
|
||||
toast.error(t("reverseShares.upload.errors.provideNameOrEmail"));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const processAllUploads = async (): Promise<void> => {
|
||||
const uploadPromises = files.map((fileWithProgress, index) => uploadFile(fileWithProgress, index));
|
||||
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
const successfulUploads = files.filter((file) => file.status === FILE_STATUS.SUCCESS);
|
||||
if (successfulUploads.length > 0) {
|
||||
toast.success(
|
||||
t("reverseShares.upload.success.countMessage", {
|
||||
count: successfulUploads.length,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!validateUploadRequirements()) return;
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
// Upload all files
|
||||
const uploadPromises = files.map((fileWithProgress, index) => uploadFile(fileWithProgress, index));
|
||||
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
const successCount = files.filter((f) => f.status === "success").length;
|
||||
if (successCount > 0) {
|
||||
toast.success(`${successCount} arquivo(s) enviado(s) com sucesso!`);
|
||||
}
|
||||
await processAllUploads();
|
||||
} catch (error) {
|
||||
console.error("Upload error:", error);
|
||||
} finally {
|
||||
@@ -196,83 +238,130 @@ export function FileUploadSection({ reverseShare, password, alias }: FileUploadS
|
||||
};
|
||||
|
||||
const canUpload = files.length > 0 && (uploaderName.trim() || uploaderEmail.trim()) && !isUploading;
|
||||
const allFilesProcessed = files.every((f) => f.status === "success" || f.status === "error");
|
||||
const allFilesProcessed = files.every(
|
||||
(file) => file.status === FILE_STATUS.SUCCESS || file.status === FILE_STATUS.ERROR
|
||||
);
|
||||
const hasSuccessfulUploads = files.some((file) => file.status === FILE_STATUS.SUCCESS);
|
||||
|
||||
// Call onUploadSuccess when all files are processed and there are successful uploads
|
||||
useEffect(() => {
|
||||
if (allFilesProcessed && hasSuccessfulUploads && files.length > 0) {
|
||||
onUploadSuccess?.();
|
||||
}
|
||||
}, [allFilesProcessed, hasSuccessfulUploads, files.length, onUploadSuccess]);
|
||||
|
||||
const getDragActiveStyles = () => {
|
||||
if (isDragActive) {
|
||||
return "border-green-500 bg-blue-50 dark:bg-green-950/20";
|
||||
}
|
||||
return "border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500";
|
||||
};
|
||||
|
||||
const getDropzoneStyles = () => {
|
||||
const baseStyles = "border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors";
|
||||
const dragStyles = getDragActiveStyles();
|
||||
const disabledStyles = isUploading ? "opacity-50 cursor-not-allowed" : "";
|
||||
|
||||
return `${baseStyles} ${dragStyles} ${disabledStyles}`.trim();
|
||||
};
|
||||
|
||||
const renderFileRestrictions = () => {
|
||||
// Calculate remaining files that can be uploaded
|
||||
const calculateRemainingFiles = (): number => {
|
||||
if (!reverseShare.maxFiles) return 0;
|
||||
const currentTotal = reverseShare.currentFileCount + files.length;
|
||||
const remaining = reverseShare.maxFiles - currentTotal;
|
||||
return Math.max(0, remaining);
|
||||
};
|
||||
|
||||
const remainingFiles = calculateRemainingFiles();
|
||||
|
||||
return (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{reverseShare.allowedFileTypes && (
|
||||
<>
|
||||
{t("reverseShares.upload.fileDropzone.acceptedTypes", { types: reverseShare.allowedFileTypes })}
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
{reverseShare.maxFileSize && (
|
||||
<>
|
||||
{t("reverseShares.upload.fileDropzone.maxFileSize", { size: formatFileSize(reverseShare.maxFileSize) })}
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
{reverseShare.maxFiles && (
|
||||
<>
|
||||
{t("reverseShares.upload.fileDropzone.remainingFiles", {
|
||||
remaining: remainingFiles,
|
||||
max: reverseShare.maxFiles,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFileStatusBadge = (fileWithProgress: FileWithProgress) => {
|
||||
if (fileWithProgress.status === FILE_STATUS.SUCCESS) {
|
||||
return (
|
||||
<Badge variant="default" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
<IconCheck className="h-3 w-3 mr-1" />
|
||||
{t("reverseShares.upload.fileList.statusUploaded")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileWithProgress.status === FILE_STATUS.ERROR) {
|
||||
return <Badge variant="destructive">{t("reverseShares.upload.fileList.statusError")}</Badge>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderFileItem = (fileWithProgress: FileWithProgress, index: number) => (
|
||||
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<IconFile className="h-5 w-5 text-gray-500 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">{fileWithProgress.file.name}</p>
|
||||
<p className="text-xs text-gray-500">{formatFileSize(fileWithProgress.file.size)}</p>
|
||||
{fileWithProgress.status === FILE_STATUS.UPLOADING && (
|
||||
<Progress value={fileWithProgress.progress} className="mt-2 h-2" />
|
||||
)}
|
||||
{fileWithProgress.status === FILE_STATUS.ERROR && (
|
||||
<p className="text-xs text-red-500 mt-1">{fileWithProgress.error}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{renderFileStatusBadge(fileWithProgress)}
|
||||
{fileWithProgress.status === FILE_STATUS.PENDING && (
|
||||
<Button size="sm" variant="ghost" onClick={() => removeFile(index)} disabled={isUploading}>
|
||||
<IconX className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* File Drop Zone */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
|
||||
${
|
||||
isDragActive
|
||||
? "border-green-500 bg-blue-50 dark:bg-green-950/20"
|
||||
: "border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500"
|
||||
}
|
||||
${isUploading ? "opacity-50 cursor-not-allowed" : ""}
|
||||
`}
|
||||
>
|
||||
<div {...getRootProps()} className={getDropzoneStyles()}>
|
||||
<input {...getInputProps()} />
|
||||
<IconUpload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
{isDragActive ? "Solte os arquivos aqui" : "Arraste arquivos aqui ou clique para selecionar"}
|
||||
{isDragActive
|
||||
? t("reverseShares.upload.fileDropzone.dragActive")
|
||||
: t("reverseShares.upload.fileDropzone.dragInactive")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{reverseShare.allowedFileTypes && (
|
||||
<>
|
||||
Tipos aceitos: {reverseShare.allowedFileTypes}
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
{reverseShare.maxFileSize && (
|
||||
<>
|
||||
Tamanho máximo: {formatFileSize(reverseShare.maxFileSize)}
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
{reverseShare.maxFiles && <>Máximo de {reverseShare.maxFiles} arquivos</>}
|
||||
</p>
|
||||
{renderFileRestrictions()}
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Arquivos selecionados:</h4>
|
||||
{files.map((fileWithProgress, index) => (
|
||||
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<IconFile className="h-5 w-5 text-gray-500 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{fileWithProgress.file.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{formatFileSize(fileWithProgress.file.size)}</p>
|
||||
{fileWithProgress.status === "uploading" && (
|
||||
<Progress value={fileWithProgress.progress} className="mt-2 h-2" />
|
||||
)}
|
||||
{fileWithProgress.status === "error" && (
|
||||
<p className="text-xs text-red-500 mt-1">{fileWithProgress.error}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{fileWithProgress.status === "success" && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
>
|
||||
<IconCheck className="h-3 w-3 mr-1" />
|
||||
Enviado
|
||||
</Badge>
|
||||
)}
|
||||
{fileWithProgress.status === "error" && <Badge variant="destructive">Erro</Badge>}
|
||||
{fileWithProgress.status === "pending" && (
|
||||
<Button size="sm" variant="ghost" onClick={() => removeFile(index)} disabled={isUploading}>
|
||||
<IconX className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">{t("reverseShares.upload.fileList.title")}</h4>
|
||||
{files.map(renderFileItem)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -282,11 +371,11 @@ export function FileUploadSection({ reverseShare, password, alias }: FileUploadS
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">
|
||||
<IconUser className="inline h-4 w-4" />
|
||||
Nome
|
||||
{t("reverseShares.upload.form.nameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Seu nome"
|
||||
placeholder={t("reverseShares.upload.form.namePlaceholder")}
|
||||
value={uploaderName}
|
||||
onChange={(e) => setUploaderName(e.target.value)}
|
||||
disabled={isUploading}
|
||||
@@ -295,12 +384,12 @@ export function FileUploadSection({ reverseShare, password, alias }: FileUploadS
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">
|
||||
<IconMail className="inline h-4 w-4" />
|
||||
E-mail
|
||||
{t("reverseShares.upload.form.emailLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="seu@email.com"
|
||||
placeholder={t("reverseShares.upload.form.emailPlaceholder")}
|
||||
value={uploaderEmail}
|
||||
onChange={(e) => setUploaderEmail(e.target.value)}
|
||||
disabled={isUploading}
|
||||
@@ -308,29 +397,31 @@ export function FileUploadSection({ reverseShare, password, alias }: FileUploadS
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Descrição (opcional)</Label>
|
||||
<Label htmlFor="description">{t("reverseShares.upload.form.descriptionLabel")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Adicione uma descrição aos arquivos..."
|
||||
placeholder={t("reverseShares.upload.form.descriptionPlaceholder")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isUploading}
|
||||
rows={3}
|
||||
rows={UPLOAD_CONFIG.TEXTAREA_ROWS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Button */}
|
||||
<Button onClick={handleUpload} disabled={!canUpload} className="w-full text-white" size="lg" variant="default">
|
||||
{isUploading ? "Enviando..." : `Enviar ${files.length} arquivo(s)`}
|
||||
{isUploading
|
||||
? t("reverseShares.upload.form.uploading")
|
||||
: t("reverseShares.upload.form.uploadButton", { count: files.length })}
|
||||
</Button>
|
||||
|
||||
{/* Success Message */}
|
||||
{allFilesProcessed && files.some((f) => f.status === "success") && (
|
||||
{allFilesProcessed && hasSuccessfulUploads && (
|
||||
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
|
||||
<p className="text-green-800 dark:text-green-200 font-medium">Arquivos enviados com sucesso! 🎉</p>
|
||||
<p className="text-green-800 dark:text-green-200 font-medium">{t("reverseShares.upload.success.title")}</p>
|
||||
<p className="text-sm text-green-600 dark:text-green-300 mt-1">
|
||||
Você pode fechar esta página ou enviar mais arquivos.
|
||||
{t("reverseShares.upload.success.description")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
6
apps/web/src/app/(shares)/r/[alias]/components/index.ts
Normal file
6
apps/web/src/app/(shares)/r/[alias]/components/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { DefaultLayout } from "./default-layout";
|
||||
export { WeTransferLayout } from "./we-transfer-layout";
|
||||
export { PasswordModal } from "./password-modal";
|
||||
export { FileUploadSection } from "./file-upload-section";
|
||||
export { StatusMessage, WeTransferStatusMessage } from "./shared/status-message";
|
||||
export { TransparentFooter } from "./transparent-footer";
|
@@ -2,21 +2,18 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { IconLock } from "@tabler/icons-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface PasswordModalProps {
|
||||
isOpen: boolean;
|
||||
onSubmit: (password: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
import { PasswordModalProps } from "../types";
|
||||
|
||||
export function PasswordModal({ isOpen, onSubmit, onClose }: PasswordModalProps) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -43,17 +40,17 @@ export function PasswordModal({ isOpen, onSubmit, onClose }: PasswordModalProps)
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900/20">
|
||||
<IconLock className="h-6 w-6 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
<DialogTitle>Link Protegido</DialogTitle>
|
||||
<DialogDescription>Este link está protegido por senha. Digite a senha para continuar.</DialogDescription>
|
||||
<DialogTitle>{t("reverseShares.upload.password.title")}</DialogTitle>
|
||||
<DialogDescription>{t("reverseShares.upload.password.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Senha</Label>
|
||||
<Label htmlFor="password">{t("reverseShares.upload.password.label")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Digite a senha"
|
||||
placeholder={t("reverseShares.upload.password.placeholder")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -64,10 +61,10 @@ export function PasswordModal({ isOpen, onSubmit, onClose }: PasswordModalProps)
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={onClose} disabled={isSubmitting}>
|
||||
Cancelar
|
||||
{t("reverseShares.upload.password.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" className="flex-1" disabled={!password.trim() || isSubmitting}>
|
||||
{isSubmitting ? "Verificando..." : "Continuar"}
|
||||
{isSubmitting ? t("reverseShares.upload.password.verifying") : t("reverseShares.upload.password.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { MESSAGE_TYPES, STATUS_VARIANTS } from "../../constants";
|
||||
import type { ReverseShareInfo } from "../../types";
|
||||
|
||||
interface StatusMessageProps {
|
||||
variant: "success" | "warning" | "error" | "info" | "neutral";
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
description: string;
|
||||
additionalText?: string;
|
||||
size?: "default" | "compact";
|
||||
}
|
||||
|
||||
interface WeTransferStatusMessageProps {
|
||||
type: keyof typeof MESSAGE_TYPES;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
showContactOwner?: boolean;
|
||||
reverseShare?: ReverseShareInfo | null;
|
||||
}
|
||||
|
||||
export function StatusMessage({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
additionalText,
|
||||
variant,
|
||||
size = "default",
|
||||
}: StatusMessageProps) {
|
||||
const styles = STATUS_VARIANTS[variant];
|
||||
const isCompact = size === "compact";
|
||||
|
||||
return (
|
||||
<div className={`text-center space-y-4 ${isCompact ? "py-6" : "py-8"}`}>
|
||||
<div className="flex justify-center">
|
||||
<div className={`${styles.iconBg} p-3 rounded-full`}>
|
||||
<Icon className={`${isCompact ? "h-6 w-6" : "h-8 w-8"} ${styles.iconColor}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className={`${isCompact ? "text-lg" : "text-xl"} font-semibold ${styles.titleColor}`}>{title}</h3>
|
||||
<p
|
||||
className={`${styles.descriptionColor} ${isCompact ? "text-sm" : ""} ${isCompact ? "" : "max-w-md mx-auto"}`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
{additionalText && (
|
||||
<p className={`${isCompact ? "text-xs" : "text-sm"} text-muted-foreground`}>{additionalText}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WeTransferStatusMessage({
|
||||
type,
|
||||
icon: Icon,
|
||||
titleKey,
|
||||
descriptionKey,
|
||||
showContactOwner = false,
|
||||
reverseShare,
|
||||
}: WeTransferStatusMessageProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
// Map message types to variants
|
||||
const getVariant = (): "success" | "warning" | "error" | "info" | "neutral" => {
|
||||
switch (type) {
|
||||
case MESSAGE_TYPES.SUCCESS:
|
||||
return "success";
|
||||
case MESSAGE_TYPES.MAX_FILES:
|
||||
return "warning";
|
||||
case MESSAGE_TYPES.INACTIVE:
|
||||
return "error";
|
||||
case MESSAGE_TYPES.EXPIRED:
|
||||
return "info";
|
||||
case MESSAGE_TYPES.NOT_FOUND:
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
};
|
||||
|
||||
const description =
|
||||
type === MESSAGE_TYPES.MAX_FILES ? t(descriptionKey, { maxFiles: reverseShare?.maxFiles || 0 }) : t(descriptionKey);
|
||||
|
||||
const additionalText = showContactOwner ? t("reverseShares.upload.maxFilesReached.contactOwner") : undefined;
|
||||
|
||||
return (
|
||||
<StatusMessage
|
||||
variant={getVariant()}
|
||||
icon={Icon}
|
||||
title={t(titleKey)}
|
||||
description={description}
|
||||
additionalText={additionalText}
|
||||
size="compact"
|
||||
/>
|
||||
);
|
||||
}
|
@@ -0,0 +1,27 @@
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { version } from "../../../../../../package.json";
|
||||
|
||||
export function TransparentFooter() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<footer className="absolute bottom-0 left-0 right-0 z-50 w-full flex items-center justify-center py-3 h-16 pointer-events-none">
|
||||
<div className="flex flex-col items-center pointer-events-auto">
|
||||
<Link
|
||||
target="_blank"
|
||||
className="flex items-center gap-1 text-white/80 hover:text-primary transition-colors"
|
||||
href="https://kyantech.com.br"
|
||||
title={t("footer.kyanHomepage")}
|
||||
>
|
||||
<span className="text-white/70 text-xs sm:text-sm">{t("footer.poweredBy")}</span>
|
||||
<p className="text-white text-xs sm:text-sm font-medium cursor-pointer hover:text-primary">
|
||||
Kyantech Solutions
|
||||
</p>
|
||||
</Link>
|
||||
<span className="text-white text-[11px] mt-1">v{version}</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
@@ -1,153 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { IconAlertTriangle, IconCheck, IconClock, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { LanguageSwitcher } from "@/components/general/language-switcher";
|
||||
import { ModeToggle } from "@/components/general/mode-toggle";
|
||||
import type { GetReverseShareForUploadResult } from "@/http/endpoints/reverse-shares/types";
|
||||
import { version } from "../../../../../../package.json";
|
||||
import { BACKGROUND_IMAGES, MESSAGE_TYPES } from "../constants";
|
||||
import { WeTransferLayoutProps } from "../types";
|
||||
import { FileUploadSection } from "./file-upload-section";
|
||||
|
||||
type ReverseShareInfo = GetReverseShareForUploadResult["data"]["reverseShare"];
|
||||
|
||||
interface WeTransferLayoutProps {
|
||||
reverseShare: ReverseShareInfo;
|
||||
password: string;
|
||||
alias: string;
|
||||
}
|
||||
|
||||
// Lista de imagens de background
|
||||
const backgroundImages = [
|
||||
"/assets/wetransfer-bgs/1.jpg",
|
||||
"/assets/wetransfer-bgs/2.jpg",
|
||||
"/assets/wetransfer-bgs/3.jpg",
|
||||
"/assets/wetransfer-bgs/4.jpg",
|
||||
"/assets/wetransfer-bgs/5.jpg",
|
||||
"/assets/wetransfer-bgs/6.jpg",
|
||||
"/assets/wetransfer-bgs/7.jpg",
|
||||
"/assets/wetransfer-bgs/8.jpg",
|
||||
];
|
||||
import { WeTransferStatusMessage } from "./shared/status-message";
|
||||
import { TransparentFooter } from "./transparent-footer";
|
||||
|
||||
// Função para escolher uma imagem aleatória
|
||||
function getRandomImage(images: string[]): string {
|
||||
const randomIndex = Math.floor(Math.random() * images.length);
|
||||
const selectedImage = images[randomIndex];
|
||||
const getRandomBackgroundImage = (): string => {
|
||||
const randomIndex = Math.floor(Math.random() * BACKGROUND_IMAGES.length);
|
||||
return BACKGROUND_IMAGES[randomIndex];
|
||||
};
|
||||
|
||||
return selectedImage;
|
||||
}
|
||||
|
||||
// Footer transparente para o layout WeTransfer
|
||||
function TransparentFooter() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<footer className="absolute bottom-0 left-0 right-0 z-50 w-full flex items-center justify-center py-3 h-16 pointer-events-none">
|
||||
<div className="flex flex-col items-center pointer-events-auto">
|
||||
<Link
|
||||
target="_blank"
|
||||
className="flex items-center gap-1 text-white/80 hover:text-primary transition-colors"
|
||||
href="https://kyantech.com.br"
|
||||
title={t("footer.kyanHomepage")}
|
||||
>
|
||||
<span className="text-white/70 text-xs sm:text-sm">{t("footer.poweredBy")}</span>
|
||||
<p className="text-white text-xs sm:text-sm font-medium cursor-pointer hover:text-primary">
|
||||
Kyantech Solutions
|
||||
</p>
|
||||
</Link>
|
||||
<span className="text-white text-[11px] mt-1">v{version}</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export function WeTransferLayout({ reverseShare, password, alias }: WeTransferLayoutProps) {
|
||||
// Hook para gerenciar a imagem de background
|
||||
const useBackgroundImage = () => {
|
||||
const [selectedImage, setSelectedImage] = useState<string>("");
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
|
||||
// Escolher uma imagem aleatória no início
|
||||
useEffect(() => {
|
||||
const randomImage = getRandomImage(backgroundImages);
|
||||
setSelectedImage(randomImage);
|
||||
setSelectedImage(getRandomBackgroundImage());
|
||||
}, []);
|
||||
|
||||
// Precarregar a imagem selecionada
|
||||
useEffect(() => {
|
||||
if (!selectedImage) return;
|
||||
|
||||
const preloadImage = () => {
|
||||
const img = new Image();
|
||||
img.onload = () => setImageLoaded(true);
|
||||
img.onerror = () => {
|
||||
console.error("Erro ao carregar imagem de background:", selectedImage);
|
||||
setImageLoaded(true);
|
||||
};
|
||||
img.src = selectedImage;
|
||||
const img = new Image();
|
||||
img.onload = () => setImageLoaded(true);
|
||||
img.onerror = () => {
|
||||
console.error("Error loading background image:", selectedImage);
|
||||
setImageLoaded(true);
|
||||
};
|
||||
|
||||
preloadImage();
|
||||
img.src = selectedImage;
|
||||
}, [selectedImage]);
|
||||
|
||||
return { selectedImage, imageLoaded };
|
||||
};
|
||||
|
||||
// Componente para controles do header
|
||||
const HeaderControls = () => (
|
||||
<div className="absolute top-4 right-4 md:top-6 md:right-6 z-40 flex items-center gap-2">
|
||||
<div className="bg-white/10 dark:bg-black/20 backdrop-blur-xs border border-white/20 dark:border-white/10 rounded-lg p-1">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<div className="bg-white/10 dark:bg-black/20 backdrop-blur-xs border border-white/20 dark:border-white/10 rounded-lg p-1">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Componente para o fundo com imagem
|
||||
const BackgroundLayer = ({ selectedImage, imageLoaded }: { selectedImage: string; imageLoaded: boolean }) => (
|
||||
<>
|
||||
<div className="absolute inset-0 z-0 bg-background" />
|
||||
{imageLoaded && selectedImage && (
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
backgroundImage: `url(${selectedImage})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/40 z-20" />
|
||||
</>
|
||||
);
|
||||
|
||||
export function WeTransferLayout({
|
||||
reverseShare,
|
||||
password,
|
||||
alias,
|
||||
isMaxFilesReached,
|
||||
hasUploadedSuccessfully,
|
||||
onUploadSuccess,
|
||||
isLinkInactive,
|
||||
isLinkNotFound,
|
||||
isLinkExpired,
|
||||
}: WeTransferLayoutProps) {
|
||||
const { selectedImage, imageLoaded } = useBackgroundImage();
|
||||
const t = useTranslations();
|
||||
|
||||
const getUploadSectionContent = () => {
|
||||
if (hasUploadedSuccessfully) {
|
||||
return (
|
||||
<WeTransferStatusMessage
|
||||
type={MESSAGE_TYPES.SUCCESS}
|
||||
icon={IconCheck}
|
||||
titleKey="reverseShares.upload.success.title"
|
||||
descriptionKey="reverseShares.upload.success.description"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLinkInactive) {
|
||||
return (
|
||||
<WeTransferStatusMessage
|
||||
type={MESSAGE_TYPES.INACTIVE}
|
||||
icon={IconAlertTriangle}
|
||||
titleKey="reverseShares.upload.linkInactive.title"
|
||||
descriptionKey="reverseShares.upload.linkInactive.description"
|
||||
showContactOwner
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLinkNotFound || !reverseShare) {
|
||||
return (
|
||||
<WeTransferStatusMessage
|
||||
type={MESSAGE_TYPES.NOT_FOUND}
|
||||
icon={IconAlertTriangle}
|
||||
titleKey="reverseShares.upload.linkNotFound.title"
|
||||
descriptionKey="reverseShares.upload.linkNotFound.description"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLinkExpired) {
|
||||
return (
|
||||
<WeTransferStatusMessage
|
||||
type={MESSAGE_TYPES.EXPIRED}
|
||||
icon={IconClock}
|
||||
titleKey="reverseShares.upload.linkExpired.title"
|
||||
descriptionKey="reverseShares.upload.linkExpired.description"
|
||||
showContactOwner
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMaxFilesReached) {
|
||||
return (
|
||||
<WeTransferStatusMessage
|
||||
type={MESSAGE_TYPES.MAX_FILES}
|
||||
icon={IconInfoCircle}
|
||||
titleKey="reverseShares.upload.maxFilesReached.title"
|
||||
descriptionKey="reverseShares.upload.maxFilesReached.description"
|
||||
showContactOwner
|
||||
reverseShare={reverseShare}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FileUploadSection
|
||||
reverseShare={reverseShare}
|
||||
password={password}
|
||||
alias={alias}
|
||||
onUploadSuccess={onUploadSuccess}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative overflow-hidden">
|
||||
{/* Fallback gradient background */}
|
||||
<div className="absolute inset-0 z-0 bg-background" />
|
||||
<BackgroundLayer selectedImage={selectedImage} imageLoaded={imageLoaded} />
|
||||
<HeaderControls />
|
||||
|
||||
{/* Background Image - imagem única aleatória */}
|
||||
{imageLoaded && selectedImage && (
|
||||
<div
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
backgroundImage: `url(${selectedImage})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dark overlay para melhor legibilidade */}
|
||||
<div className="absolute inset-0 bg-black/40 z-20" />
|
||||
|
||||
{/* Controles - Topo Direito */}
|
||||
<div className="absolute top-4 right-4 md:top-6 md:right-6 z-40 flex items-center gap-2">
|
||||
<div className="bg-white/10 dark:bg-black/20 backdrop-blur-xs border border-white/20 dark:border-white/10 rounded-lg p-1">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<div className="bg-white/10 dark:bg-black/20 backdrop-blur-xs border border-white/20 dark:border-white/10 rounded-lg p-1">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading indicator para a imagem */}
|
||||
{/* Loading indicator */}
|
||||
{!imageLoaded && (
|
||||
<div className="absolute inset-0 z-30 flex items-center justify-center">
|
||||
<div className="animate-pulse text-white/70 text-sm">Carregando...</div>
|
||||
<div className="animate-pulse text-white/70 text-sm">{t("reverseShares.upload.layout.loading")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content - Alinhado à esquerda */}
|
||||
{/* Main Content */}
|
||||
<div className="relative z-30 min-h-screen flex items-center justify-start p-4 md:p-8 lg:p-12 xl:p-16">
|
||||
<div className="w-full max-w-md lg:max-w-lg xl:max-w-xl">
|
||||
<div className="bg-white dark:bg-black rounded-2xl shadow-2xl p-6 md:p-8 backdrop-blur-sm border border-white/20">
|
||||
{/* Header */}
|
||||
<div className="text-left mb-6 md:mb-8">
|
||||
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{reverseShare.name || "Enviar Arquivos"}
|
||||
{reverseShare?.name || t("reverseShares.upload.layout.defaultTitle")}
|
||||
</h1>
|
||||
{reverseShare.description && (
|
||||
{reverseShare?.description && (
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm md:text-base">{reverseShare.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Section */}
|
||||
<FileUploadSection reverseShare={reverseShare} password={password} alias={alias} />
|
||||
{getUploadSectionContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer transparente */}
|
||||
<TransparentFooter />
|
||||
</div>
|
||||
);
|
||||
|
84
apps/web/src/app/(shares)/r/[alias]/constants/index.ts
Normal file
84
apps/web/src/app/(shares)/r/[alias]/constants/index.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// HTTP Status Constants
|
||||
export const HTTP_STATUS = {
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
GONE: 410,
|
||||
} as const;
|
||||
|
||||
// Error Messages
|
||||
export const ERROR_MESSAGES = {
|
||||
PASSWORD_REQUIRED: "Password required",
|
||||
INVALID_PASSWORD: "Invalid password",
|
||||
} as const;
|
||||
|
||||
// Error types
|
||||
export type ErrorType = "inactive" | "notFound" | "expired" | "generic" | null;
|
||||
|
||||
export const STATUS_VARIANTS = {
|
||||
success: {
|
||||
iconBg: "bg-green-100 dark:bg-green-900/20",
|
||||
iconColor: "text-green-600 dark:text-green-400",
|
||||
titleColor: "text-green-800 dark:text-green-200",
|
||||
descriptionColor: "text-green-600 dark:text-green-300",
|
||||
},
|
||||
warning: {
|
||||
iconBg: "bg-amber-100 dark:bg-amber-900/20",
|
||||
iconColor: "text-amber-600 dark:text-amber-400",
|
||||
titleColor: "text-foreground",
|
||||
descriptionColor: "text-muted-foreground",
|
||||
},
|
||||
error: {
|
||||
iconBg: "bg-red-100 dark:bg-red-900/20",
|
||||
iconColor: "text-red-600 dark:text-red-400",
|
||||
titleColor: "text-foreground",
|
||||
descriptionColor: "text-muted-foreground",
|
||||
},
|
||||
info: {
|
||||
iconBg: "bg-orange-100 dark:bg-orange-900/20",
|
||||
iconColor: "text-orange-600 dark:text-orange-400",
|
||||
titleColor: "text-foreground",
|
||||
descriptionColor: "text-muted-foreground",
|
||||
},
|
||||
neutral: {
|
||||
iconBg: "bg-gray-100 dark:bg-gray-900/20",
|
||||
iconColor: "text-gray-600 dark:text-gray-400",
|
||||
titleColor: "text-foreground",
|
||||
descriptionColor: "text-muted-foreground",
|
||||
},
|
||||
};
|
||||
|
||||
export const UPLOAD_PROGRESS = {
|
||||
INITIAL: 0,
|
||||
COMPLETE: 100,
|
||||
} as const;
|
||||
|
||||
export const FILE_STATUS = {
|
||||
PENDING: "pending",
|
||||
UPLOADING: "uploading",
|
||||
SUCCESS: "success",
|
||||
ERROR: "error",
|
||||
} as const;
|
||||
|
||||
export const UPLOAD_CONFIG = {
|
||||
TEXTAREA_ROWS: 3,
|
||||
} as const;
|
||||
|
||||
export const MESSAGE_TYPES = {
|
||||
SUCCESS: "SUCCESS",
|
||||
MAX_FILES: "MAX_FILES",
|
||||
INACTIVE: "INACTIVE",
|
||||
NOT_FOUND: "NOT_FOUND",
|
||||
EXPIRED: "EXPIRED",
|
||||
} as const;
|
||||
|
||||
export const BACKGROUND_IMAGES = [
|
||||
"/assets/wetransfer-bgs/1.jpg",
|
||||
"/assets/wetransfer-bgs/2.jpg",
|
||||
"/assets/wetransfer-bgs/3.jpg",
|
||||
"/assets/wetransfer-bgs/4.jpg",
|
||||
"/assets/wetransfer-bgs/5.jpg",
|
||||
"/assets/wetransfer-bgs/6.jpg",
|
||||
"/assets/wetransfer-bgs/7.jpg",
|
||||
"/assets/wetransfer-bgs/8.jpg",
|
||||
] as const;
|
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getReverseShareForUploadByAlias } from "@/http/endpoints";
|
||||
import { ERROR_MESSAGES, HTTP_STATUS, type ErrorType } from "../constants";
|
||||
import type { ReverseShareInfo } from "../types";
|
||||
|
||||
interface UseReverseShareUploadProps {
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
// States
|
||||
const [reverseShare, setReverseShare] = useState<ReverseShareInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [hasUploadedSuccessfully, setHasUploadedSuccessfully] = useState(false);
|
||||
const [error, setError] = useState<{ type: ErrorType }>({ type: null });
|
||||
|
||||
// Utility functions
|
||||
const redirectToHome = () => router.push("/");
|
||||
|
||||
const checkIfMaxFilesReached = (reverseShareData: ReverseShareInfo): boolean => {
|
||||
if (!reverseShareData.maxFiles) return false;
|
||||
return reverseShareData.currentFileCount >= reverseShareData.maxFiles;
|
||||
};
|
||||
|
||||
const handleErrorResponse = (responseError: any) => {
|
||||
const status = responseError.response?.status;
|
||||
const errorMessage = responseError.response?.data?.error;
|
||||
|
||||
switch (status) {
|
||||
case HTTP_STATUS.UNAUTHORIZED:
|
||||
if (errorMessage === ERROR_MESSAGES.PASSWORD_REQUIRED) {
|
||||
setIsPasswordModalOpen(true);
|
||||
} else if (errorMessage === ERROR_MESSAGES.INVALID_PASSWORD) {
|
||||
setIsPasswordModalOpen(true);
|
||||
toast.error(t("reverseShares.upload.errors.passwordIncorrect"));
|
||||
}
|
||||
break;
|
||||
|
||||
case HTTP_STATUS.NOT_FOUND:
|
||||
setError({ type: "notFound" });
|
||||
break;
|
||||
|
||||
case HTTP_STATUS.FORBIDDEN:
|
||||
setError({ type: "inactive" });
|
||||
break;
|
||||
|
||||
case HTTP_STATUS.GONE:
|
||||
setError({ type: "expired" });
|
||||
break;
|
||||
|
||||
default:
|
||||
setError({ type: "generic" });
|
||||
toast.error(t("reverseShares.upload.errors.loadFailed"));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const loadReverseShare = async (passwordAttempt?: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError({ type: null });
|
||||
|
||||
const response = await getReverseShareForUploadByAlias(
|
||||
alias,
|
||||
passwordAttempt ? { password: passwordAttempt } : undefined
|
||||
);
|
||||
|
||||
setReverseShare(response.data.reverseShare);
|
||||
setIsPasswordModalOpen(false);
|
||||
setCurrentPassword(passwordAttempt || "");
|
||||
} catch (responseError: any) {
|
||||
console.error("Failed to load reverse share:", responseError);
|
||||
handleErrorResponse(responseError);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = (passwordValue: string) => {
|
||||
loadReverseShare(passwordValue);
|
||||
};
|
||||
|
||||
const handlePasswordModalClose = () => {
|
||||
redirectToHome();
|
||||
};
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
setHasUploadedSuccessfully(true);
|
||||
};
|
||||
|
||||
const resetUploadSuccess = () => {
|
||||
setHasUploadedSuccessfully(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (alias) {
|
||||
loadReverseShare();
|
||||
}
|
||||
}, [alias]);
|
||||
|
||||
// Computed values
|
||||
const isMaxFilesReached = reverseShare ? checkIfMaxFilesReached(reverseShare) : false;
|
||||
const isWeTransferLayout = reverseShare?.pageLayout === "WETRANSFER";
|
||||
const hasError = error.type !== null || (!reverseShare && !isLoading && !isPasswordModalOpen);
|
||||
|
||||
// Error state booleans for backward compatibility
|
||||
const isLinkInactive = error.type === "inactive";
|
||||
const isLinkNotFound = error.type === "notFound" || (!reverseShare && !isLoading && !isPasswordModalOpen);
|
||||
const isLinkExpired = error.type === "expired";
|
||||
|
||||
return {
|
||||
// Data
|
||||
reverseShare,
|
||||
currentPassword,
|
||||
alias,
|
||||
|
||||
// States
|
||||
isLoading,
|
||||
isPasswordModalOpen,
|
||||
hasUploadedSuccessfully,
|
||||
error: error.type,
|
||||
isMaxFilesReached,
|
||||
isWeTransferLayout,
|
||||
hasError,
|
||||
|
||||
// Error states (for backward compatibility)
|
||||
isLinkInactive,
|
||||
isLinkNotFound,
|
||||
isLinkExpired,
|
||||
|
||||
// Actions
|
||||
handlePasswordSubmit,
|
||||
handlePasswordModalClose,
|
||||
handleUploadSuccess,
|
||||
resetUploadSuccess,
|
||||
loadReverseShare,
|
||||
redirectToHome,
|
||||
};
|
||||
}
|
@@ -1,9 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Enviar Arquivos - Palmr",
|
||||
description: "Envie arquivos através do link compartilhado",
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: t("reverseShares.upload.metadata.title"),
|
||||
description: t("reverseShares.upload.metadata.description"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function ReverseShareLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
|
@@ -1,93 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { getReverseShareForUploadByAlias } from "@/http/endpoints";
|
||||
import type { GetReverseShareForUploadResult } from "@/http/endpoints/reverse-shares/types";
|
||||
import { DefaultLayout } from "./components/default-layout";
|
||||
import { PasswordModal } from "./components/password-modal";
|
||||
import { WeTransferLayout } from "./components/we-transfer-layout";
|
||||
|
||||
type ReverseShareInfo = GetReverseShareForUploadResult["data"]["reverseShare"];
|
||||
import { DefaultLayout, PasswordModal, WeTransferLayout } from "./components";
|
||||
import { useReverseShareUpload } from "./hooks/use-reverse-share-upload";
|
||||
|
||||
export default function ReverseShareUploadPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const alias = params?.alias as string;
|
||||
const shareAlias = params?.alias as string;
|
||||
|
||||
const [reverseShare, setReverseShare] = useState<ReverseShareInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const loadReverseShare = async (passwordAttempt?: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await getReverseShareForUploadByAlias(
|
||||
alias,
|
||||
passwordAttempt ? { password: passwordAttempt } : undefined
|
||||
);
|
||||
setReverseShare(response.data.reverseShare);
|
||||
setShowPasswordModal(false);
|
||||
setPassword(passwordAttempt || "");
|
||||
} catch (error: any) {
|
||||
console.error("Failed to load reverse share:", error);
|
||||
|
||||
if (error.response?.status === 401 && error.response?.data?.error === "Password required") {
|
||||
setShowPasswordModal(true);
|
||||
} else if (error.response?.status === 401 && error.response?.data?.error === "Invalid password") {
|
||||
setShowPasswordModal(true);
|
||||
toast.error("Senha incorreta. Tente novamente.");
|
||||
} else if (error.response?.status === 404) {
|
||||
toast.error("Link não encontrado ou expirado.");
|
||||
router.push("/");
|
||||
} else if (error.response?.status === 403) {
|
||||
toast.error("Este link está inativo.");
|
||||
router.push("/");
|
||||
} else if (error.response?.status === 410) {
|
||||
toast.error("Este link expirou.");
|
||||
router.push("/");
|
||||
} else {
|
||||
toast.error("Erro ao carregar informações. Tente novamente.");
|
||||
router.push("/");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (alias) {
|
||||
loadReverseShare();
|
||||
}
|
||||
}, [alias]);
|
||||
|
||||
const handlePasswordSubmit = (passwordValue: string) => {
|
||||
loadReverseShare(passwordValue);
|
||||
};
|
||||
const {
|
||||
reverseShare,
|
||||
currentPassword,
|
||||
isLoading,
|
||||
isPasswordModalOpen,
|
||||
hasUploadedSuccessfully,
|
||||
isMaxFilesReached,
|
||||
isWeTransferLayout,
|
||||
hasError,
|
||||
isLinkInactive,
|
||||
isLinkNotFound,
|
||||
isLinkExpired,
|
||||
handlePasswordSubmit,
|
||||
handlePasswordModalClose,
|
||||
handleUploadSuccess,
|
||||
} = useReverseShareUpload({ alias: shareAlias });
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (showPasswordModal) {
|
||||
// Password required state
|
||||
if (isPasswordModalOpen) {
|
||||
return (
|
||||
<PasswordModal isOpen={showPasswordModal} onSubmit={handlePasswordSubmit} onClose={() => router.push("/")} />
|
||||
<PasswordModal isOpen={isPasswordModalOpen} onSubmit={handlePasswordSubmit} onClose={handlePasswordModalClose} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!reverseShare) {
|
||||
return <LoadingScreen />;
|
||||
// Error states or missing data - always use DefaultLayout for simplicity
|
||||
if (hasError) {
|
||||
return (
|
||||
<DefaultLayout
|
||||
reverseShare={reverseShare}
|
||||
password={currentPassword}
|
||||
alias={shareAlias}
|
||||
isMaxFilesReached={false}
|
||||
hasUploadedSuccessfully={false}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
isLinkInactive={isLinkInactive}
|
||||
isLinkNotFound={isLinkNotFound}
|
||||
isLinkExpired={isLinkExpired}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Escolher o layout baseado no pageLayout
|
||||
if (reverseShare.pageLayout === "WETRANSFER") {
|
||||
return <WeTransferLayout reverseShare={reverseShare} password={password} alias={alias} />;
|
||||
// Render appropriate layout for normal states
|
||||
if (isWeTransferLayout) {
|
||||
return (
|
||||
<WeTransferLayout
|
||||
reverseShare={reverseShare}
|
||||
password={currentPassword}
|
||||
alias={shareAlias}
|
||||
isMaxFilesReached={isMaxFilesReached}
|
||||
hasUploadedSuccessfully={hasUploadedSuccessfully}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
isLinkInactive={false}
|
||||
isLinkNotFound={false}
|
||||
isLinkExpired={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Layout padrão
|
||||
return <DefaultLayout reverseShare={reverseShare} password={password} alias={alias} />;
|
||||
return (
|
||||
<DefaultLayout
|
||||
reverseShare={reverseShare}
|
||||
password={currentPassword}
|
||||
alias={shareAlias}
|
||||
isMaxFilesReached={isMaxFilesReached}
|
||||
hasUploadedSuccessfully={hasUploadedSuccessfully}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
isLinkInactive={false}
|
||||
isLinkNotFound={false}
|
||||
isLinkExpired={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
49
apps/web/src/app/(shares)/r/[alias]/types/index.tsx
Normal file
49
apps/web/src/app/(shares)/r/[alias]/types/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { GetReverseShareForUploadResult } from "@/http/endpoints/reverse-shares/types";
|
||||
import { FILE_STATUS } from "../constants";
|
||||
|
||||
export type ReverseShareInfo = GetReverseShareForUploadResult["data"]["reverseShare"];
|
||||
export type FileStatus = (typeof FILE_STATUS)[keyof typeof FILE_STATUS];
|
||||
|
||||
export interface DefaultLayoutProps {
|
||||
reverseShare: ReverseShareInfo | null;
|
||||
password: string;
|
||||
alias: string;
|
||||
isMaxFilesReached: boolean;
|
||||
hasUploadedSuccessfully: boolean;
|
||||
onUploadSuccess: () => void;
|
||||
isLinkInactive: boolean;
|
||||
isLinkNotFound: boolean;
|
||||
isLinkExpired: boolean;
|
||||
}
|
||||
|
||||
export interface FileUploadSectionProps {
|
||||
reverseShare: ReverseShareInfo;
|
||||
password: string;
|
||||
alias: string;
|
||||
onUploadSuccess?: () => void;
|
||||
}
|
||||
|
||||
export interface FileWithProgress {
|
||||
file: File;
|
||||
progress: number;
|
||||
status: FileStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PasswordModalProps {
|
||||
isOpen: boolean;
|
||||
onSubmit: (password: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface WeTransferLayoutProps {
|
||||
reverseShare: ReverseShareInfo | null;
|
||||
password: string;
|
||||
alias: string;
|
||||
isMaxFilesReached: boolean;
|
||||
hasUploadedSuccessfully: boolean;
|
||||
onUploadSuccess: () => void;
|
||||
isLinkInactive: boolean;
|
||||
isLinkNotFound: boolean;
|
||||
isLinkExpired: boolean;
|
||||
}
|
Reference in New Issue
Block a user