feat: Initialize frontend with React, Vite, and Tailwind CSS
- Set up main entry point for React application. - Create About, Home, NotFound, Privacy, and Terms pages with SEO support. - Implement API service for file uploads and task management. - Add global styles using Tailwind CSS. - Create utility functions for SEO and text processing. - Configure Vite for development and production builds. - Set up Nginx configuration for serving frontend and backend. - Add scripts for cleanup of expired files and sitemap generation. - Implement deployment script for production environment.
This commit is contained in:
88
frontend/src/components/shared/DownloadButton.tsx
Normal file
88
frontend/src/components/shared/DownloadButton.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download, RotateCcw, Clock } from 'lucide-react';
|
||||
import type { TaskResult } from '@/services/api';
|
||||
import { formatFileSize } from '@/utils/textTools';
|
||||
|
||||
interface DownloadButtonProps {
|
||||
/** Task result containing download URL */
|
||||
result: TaskResult;
|
||||
/** Called when user wants to start over */
|
||||
onStartOver: () => void;
|
||||
}
|
||||
|
||||
export default function DownloadButton({ result, onStartOver }: DownloadButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!result.download_url) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-emerald-50 p-6 ring-1 ring-emerald-200">
|
||||
{/* Success header */}
|
||||
<div className="mb-4 text-center">
|
||||
<p className="text-lg font-semibold text-emerald-800">
|
||||
{t('result.conversionComplete')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-emerald-600">
|
||||
{t('result.downloadReady')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* File stats */}
|
||||
{(result.original_size || result.compressed_size) && (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{result.original_size && (
|
||||
<div className="rounded-lg bg-white p-3 text-center">
|
||||
<p className="text-xs text-slate-500">{t('result.originalSize')}</p>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{formatFileSize(result.original_size)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{result.compressed_size && (
|
||||
<div className="rounded-lg bg-white p-3 text-center">
|
||||
<p className="text-xs text-slate-500">{t('result.newSize')}</p>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{formatFileSize(result.compressed_size)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{result.reduction_percent !== undefined && (
|
||||
<div className="rounded-lg bg-white p-3 text-center">
|
||||
<p className="text-xs text-slate-500">{t('result.reduction')}</p>
|
||||
<p className="text-sm font-semibold text-emerald-600">
|
||||
{result.reduction_percent}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download button */}
|
||||
<a
|
||||
href={result.download_url}
|
||||
download={result.filename}
|
||||
className="btn-success w-full"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
{t('common.download')} — {result.filename}
|
||||
</a>
|
||||
|
||||
{/* Expiry notice */}
|
||||
<div className="mt-3 flex items-center justify-center gap-1.5 text-xs text-slate-500">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{t('result.linkExpiry')}
|
||||
</div>
|
||||
|
||||
{/* Start over */}
|
||||
<button
|
||||
onClick={onStartOver}
|
||||
className="mt-4 flex w-full items-center justify-center gap-2 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
{t('common.startOver')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
frontend/src/components/shared/FileUploader.tsx
Normal file
132
frontend/src/components/shared/FileUploader.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useDropzone, type Accept } from 'react-dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Upload, File, X } from 'lucide-react';
|
||||
import { formatFileSize } from '@/utils/textTools';
|
||||
|
||||
interface FileUploaderProps {
|
||||
/** Called when a file is selected/dropped */
|
||||
onFileSelect: (file: File) => void;
|
||||
/** Currently selected file */
|
||||
file: File | null;
|
||||
/** Accepted MIME types */
|
||||
accept?: Accept;
|
||||
/** Maximum file size in MB */
|
||||
maxSizeMB?: number;
|
||||
/** Whether upload is in progress */
|
||||
isUploading?: boolean;
|
||||
/** Upload progress percentage */
|
||||
uploadProgress?: number;
|
||||
/** Error message */
|
||||
error?: string | null;
|
||||
/** Reset handler */
|
||||
onReset?: () => void;
|
||||
/** Descriptive text for accepted file types */
|
||||
acceptLabel?: string;
|
||||
}
|
||||
|
||||
export default function FileUploader({
|
||||
onFileSelect,
|
||||
file,
|
||||
accept,
|
||||
maxSizeMB = 20,
|
||||
isUploading = false,
|
||||
uploadProgress = 0,
|
||||
error,
|
||||
onReset,
|
||||
acceptLabel,
|
||||
}: FileUploaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
onFileSelect(acceptedFiles[0]);
|
||||
}
|
||||
},
|
||||
[onFileSelect]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept,
|
||||
maxFiles: 1,
|
||||
maxSize: maxSizeMB * 1024 * 1024,
|
||||
disabled: isUploading,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Drop Zone */}
|
||||
{!file && (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`upload-zone ${isDragActive ? 'drag-active' : ''}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload
|
||||
className={`mb-4 h-12 w-12 ${
|
||||
isDragActive ? 'text-primary-500' : 'text-slate-400'
|
||||
}`}
|
||||
/>
|
||||
<p className="mb-2 text-base font-medium text-slate-700">
|
||||
{t('common.dragDrop')}
|
||||
</p>
|
||||
{acceptLabel && (
|
||||
<p className="text-sm text-slate-500">{acceptLabel}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
{t('common.maxSize', { size: maxSizeMB })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected File */}
|
||||
{file && !isUploading && (
|
||||
<div className="flex items-center gap-3 rounded-xl bg-primary-50 p-4 ring-1 ring-primary-200">
|
||||
<File className="h-8 w-8 flex-shrink-0 text-primary-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-slate-900">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
{onReset && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-200 hover:text-slate-600"
|
||||
aria-label="Remove file"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Progress */}
|
||||
{isUploading && (
|
||||
<div className="rounded-xl bg-slate-50 p-4 ring-1 ring-slate-200">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{t('common.upload')}...
|
||||
</span>
|
||||
<span className="text-sm text-slate-500">{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-200">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary-600 transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mt-3 rounded-xl bg-red-50 p-3 ring-1 ring-red-200">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
frontend/src/components/shared/ProgressBar.tsx
Normal file
42
frontend/src/components/shared/ProgressBar.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface ProgressBarProps {
|
||||
/** Current task state */
|
||||
state: 'PENDING' | 'PROCESSING' | 'SUCCESS' | 'FAILURE' | string;
|
||||
/** Progress message */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export default function ProgressBar({ state, message }: ProgressBarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isActive = state === 'PENDING' || state === 'PROCESSING';
|
||||
const isComplete = state === 'SUCCESS';
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-slate-50 p-5 ring-1 ring-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
{isActive && (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary-600" />
|
||||
)}
|
||||
{isComplete && (
|
||||
<CheckCircle2 className="h-6 w-6 text-emerald-600" />
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{message || t('common.processing')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Animated progress bar for active states */}
|
||||
{isActive && (
|
||||
<div className="mt-3 h-1.5 w-full overflow-hidden rounded-full bg-slate-200">
|
||||
<div className="progress-bar-animated h-full w-2/3 rounded-full bg-primary-500 transition-all" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/shared/ToolCard.tsx
Normal file
43
frontend/src/components/shared/ToolCard.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface ToolCardProps {
|
||||
/** Tool route path */
|
||||
to: string;
|
||||
/** Tool title */
|
||||
title: string;
|
||||
/** Short description */
|
||||
description: string;
|
||||
/** Pre-rendered icon element */
|
||||
icon: ReactNode;
|
||||
/** Icon background color class */
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
export default function ToolCard({
|
||||
to,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
bgColor,
|
||||
}: ToolCardProps) {
|
||||
return (
|
||||
<Link to={to} className="tool-card group block">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl ${bgColor}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold text-slate-900 group-hover:text-primary-600 transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-slate-500 line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user