feat: add support for Amazon S3 and Google Cloud Storage buckets and install document preview dependencies
This commit is contained in:
parent
099b6423ef
commit
80f68d28bd
929
package-lock.json
generated
929
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@react-pdf-viewer/core": "^3.12.0",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tiptap/extension-color": "^3.20.4",
|
||||
@ -28,7 +29,9 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"docx-preview": "^0.3.7",
|
||||
"lucide-react": "^0.562.0",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.1",
|
||||
@ -40,6 +43,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -54,53 +54,66 @@ const getBucketSchema = (isEdit: boolean) =>
|
||||
.min(1, "Bucket name is required")
|
||||
.max(255, "Maximum 255 characters allowed"),
|
||||
description: z.string().optional().or(z.literal("")),
|
||||
storage_type: z.enum(["azure", "local"], {
|
||||
storage_type: z.enum(["azure", "local", "s3", "gcp"], {
|
||||
error: "Storage type is required",
|
||||
}),
|
||||
// Azure
|
||||
azure_account: z.string().optional().or(z.literal("")),
|
||||
azure_container: z.string().optional().or(z.literal("")),
|
||||
azure_sas_token: z.string().optional().or(z.literal("")),
|
||||
azure_url: z.string().optional().or(z.literal("")),
|
||||
// Amazon S3
|
||||
s3_bucket: z.string().optional().or(z.literal("")),
|
||||
s3_region: z.string().optional().or(z.literal("")),
|
||||
s3_access_key_id: z.string().optional().or(z.literal("")),
|
||||
s3_secret_access_key: z.string().optional().or(z.literal("")),
|
||||
// GCP
|
||||
gcs_bucket: z.string().optional().or(z.literal("")),
|
||||
gcs_project_id: z.string().optional().or(z.literal("")),
|
||||
gcs_service_account_key: z.string().optional().or(z.literal("")),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.storage_type === "azure") {
|
||||
if (!data.azure_account || data.azure_account.trim() === "") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["azure_account"],
|
||||
message: "Storage account is required for Azure",
|
||||
});
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["azure_account"], message: "Storage account is required for Azure" });
|
||||
}
|
||||
if (!data.azure_container || data.azure_container.trim() === "") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["azure_container"],
|
||||
message: "Container name is required for Azure",
|
||||
});
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["azure_container"], message: "Container name is required for Azure" });
|
||||
}
|
||||
if (!data.azure_url || data.azure_url.trim() === "") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["azure_url"],
|
||||
message: "Container URL is required for Azure",
|
||||
});
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["azure_url"], message: "Container URL is required for Azure" });
|
||||
} else {
|
||||
try {
|
||||
new URL(data.azure_url);
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["azure_url"],
|
||||
message: "Please enter a valid URL",
|
||||
});
|
||||
try { new URL(data.azure_url); } catch {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["azure_url"], message: "Please enter a valid URL" });
|
||||
}
|
||||
}
|
||||
if (!isEdit && (!data.azure_sas_token || data.azure_sas_token.trim() === "")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["azure_sas_token"],
|
||||
message: "SAS token is required for Azure",
|
||||
});
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["azure_sas_token"], message: "SAS token is required for Azure" });
|
||||
}
|
||||
}
|
||||
if (data.storage_type === "s3") {
|
||||
if (!data.s3_bucket || data.s3_bucket.trim() === "") {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["s3_bucket"], message: "Bucket name is required for Amazon S3" });
|
||||
}
|
||||
if (!data.s3_region || data.s3_region.trim() === "") {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["s3_region"], message: "AWS region is required for Amazon S3" });
|
||||
}
|
||||
if (!data.s3_access_key_id || data.s3_access_key_id.trim() === "") {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["s3_access_key_id"], message: "Access Key ID is required for Amazon S3" });
|
||||
}
|
||||
if (!isEdit && (!data.s3_secret_access_key || data.s3_secret_access_key.trim() === "")) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["s3_secret_access_key"], message: "Secret Access Key is required for Amazon S3" });
|
||||
}
|
||||
}
|
||||
if (data.storage_type === "gcp") {
|
||||
if (!data.gcs_bucket || data.gcs_bucket.trim() === "") {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["gcs_bucket"], message: "Bucket name is required for GCP" });
|
||||
}
|
||||
if (!data.gcs_project_id || data.gcs_project_id.trim() === "") {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["gcs_project_id"], message: "Project ID is required for GCP" });
|
||||
}
|
||||
if (!isEdit && (!data.gcs_service_account_key || data.gcs_service_account_key.trim() === "")) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["gcs_service_account_key"], message: "Service Account Key JSON is required for GCP" });
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -131,6 +144,13 @@ const BucketFormModal = ({ isOpen, onClose, onSuccess, bucket }: BucketFormProps
|
||||
azure_container: "",
|
||||
azure_sas_token: "",
|
||||
azure_url: "",
|
||||
s3_bucket: "",
|
||||
s3_region: "",
|
||||
s3_access_key_id: "",
|
||||
s3_secret_access_key: "",
|
||||
gcs_bucket: "",
|
||||
gcs_project_id: "",
|
||||
gcs_service_account_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
@ -145,8 +165,15 @@ const BucketFormModal = ({ isOpen, onClose, onSuccess, bucket }: BucketFormProps
|
||||
storage_type: bucket.storage_type,
|
||||
azure_account: bucket.azure_account || "",
|
||||
azure_container: bucket.azure_container || "",
|
||||
azure_sas_token: "", // never pre-fill the masked token
|
||||
azure_sas_token: "",
|
||||
azure_url: bucket.azure_url || "",
|
||||
s3_bucket: bucket.s3_bucket || "",
|
||||
s3_region: bucket.s3_region || "",
|
||||
s3_access_key_id: bucket.s3_access_key_id || "",
|
||||
s3_secret_access_key: "",
|
||||
gcs_bucket: bucket.gcs_bucket || "",
|
||||
gcs_project_id: bucket.gcs_project_id || "",
|
||||
gcs_service_account_key: "",
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
@ -157,6 +184,13 @@ const BucketFormModal = ({ isOpen, onClose, onSuccess, bucket }: BucketFormProps
|
||||
azure_container: "",
|
||||
azure_sas_token: "",
|
||||
azure_url: "",
|
||||
s3_bucket: "",
|
||||
s3_region: "",
|
||||
s3_access_key_id: "",
|
||||
s3_secret_access_key: "",
|
||||
gcs_bucket: "",
|
||||
gcs_project_id: "",
|
||||
gcs_service_account_key: "",
|
||||
});
|
||||
}
|
||||
clearErrors();
|
||||
@ -167,9 +201,11 @@ const BucketFormModal = ({ isOpen, onClose, onSuccess, bucket }: BucketFormProps
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit && bucket) {
|
||||
// Don't send empty SAS token on edit (means "don't change it")
|
||||
const payload = { ...data };
|
||||
// Don't send blank secrets on edit (means "don't change")
|
||||
if (!payload.azure_sas_token) delete payload.azure_sas_token;
|
||||
if (!payload.s3_secret_access_key) delete payload.s3_secret_access_key;
|
||||
if (!payload.gcs_service_account_key) delete payload.gcs_service_account_key;
|
||||
await storageBucketService.update(bucket.id, payload as any);
|
||||
showToast.success("Bucket updated successfully");
|
||||
} else {
|
||||
@ -218,10 +254,12 @@ const BucketFormModal = ({ isOpen, onClose, onSuccess, bucket }: BucketFormProps
|
||||
placeholder="Select Storage Type"
|
||||
options={[
|
||||
{ value: "azure", label: "Azure Blob Storage" },
|
||||
{ value: "s3", label: "Amazon S3" },
|
||||
{ value: "gcp", label: "Google Cloud Storage (GCS)" },
|
||||
{ value: "local", label: "Local Filesystem" },
|
||||
]}
|
||||
value={storageTypeValue}
|
||||
onValueChange={(val) => setValue("storage_type", val as "azure" | "local", { shouldValidate: true })}
|
||||
onValueChange={(val) => setValue("storage_type", val as "azure" | "local" | "s3" | "gcp", { shouldValidate: true })}
|
||||
error={errors.storage_type?.message}
|
||||
/>
|
||||
|
||||
@ -270,6 +308,86 @@ const BucketFormModal = ({ isOpen, onClose, onSuccess, bucket }: BucketFormProps
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Amazon S3 Fields */}
|
||||
{storageTypeValue === "s3" && (
|
||||
<div className="space-y-3 p-4 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<p className="text-xs font-semibold text-orange-700 uppercase tracking-wider">
|
||||
Amazon S3 Configuration
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
label="Bucket Name"
|
||||
required
|
||||
placeholder="e.g. qassure-prod-files"
|
||||
error={errors.s3_bucket?.message}
|
||||
{...register("s3_bucket")}
|
||||
/>
|
||||
<FormField
|
||||
label="AWS Region"
|
||||
required
|
||||
placeholder="e.g. us-east-1"
|
||||
error={errors.s3_region?.message}
|
||||
{...register("s3_region")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Access Key ID"
|
||||
required
|
||||
placeholder="e.g. AKIAIOSFODNN7EXAMPLE"
|
||||
error={errors.s3_access_key_id?.message}
|
||||
{...register("s3_access_key_id")}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label={`Secret Access Key${isEdit ? " (leave blank to keep existing)" : ""}`}
|
||||
type="password"
|
||||
required={!isEdit}
|
||||
placeholder={isEdit ? "••••••••••••••••" : "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}
|
||||
error={errors.s3_secret_access_key?.message}
|
||||
helperText="Stored encrypted (AES-256-GCM). Never shown again in plaintext."
|
||||
{...register("s3_secret_access_key")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GCP Cloud Storage Fields */}
|
||||
{storageTypeValue === "gcp" && (
|
||||
<div className="space-y-3 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p className="text-xs font-semibold text-green-700 uppercase tracking-wider">
|
||||
Google Cloud Storage Configuration
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
label="GCS Bucket Name"
|
||||
required
|
||||
placeholder="e.g. qassure-gcs-files"
|
||||
error={errors.gcs_bucket?.message}
|
||||
{...register("gcs_bucket")}
|
||||
/>
|
||||
<FormField
|
||||
label="GCP Project ID"
|
||||
required
|
||||
placeholder="e.g. my-gcp-project-123"
|
||||
error={errors.gcs_project_id?.message}
|
||||
{...register("gcs_project_id")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label={`Service Account Key JSON${isEdit ? " (leave blank to keep existing)" : ""}`}
|
||||
type="password"
|
||||
required={!isEdit}
|
||||
placeholder={isEdit ? "••••••••••••••••" : "{\"type\": \"service_account\", ...}"}
|
||||
error={errors.gcs_service_account_key?.message}
|
||||
helperText="Paste the full service account JSON key. Stored encrypted (AES-256-GCM)."
|
||||
{...register("gcs_service_account_key")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
|
||||
<button
|
||||
@ -476,25 +594,52 @@ const StorageBucketsPage = () => {
|
||||
{
|
||||
key: "storage_type",
|
||||
label: "Type",
|
||||
render: (b) => (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-50 text-indigo-700 border border-indigo-200">
|
||||
render: (b) => {
|
||||
const typeMap: Record<string, { label: string; color: string }> = {
|
||||
azure: { label: "Azure Blob", color: "bg-blue-50 text-blue-700 border-blue-200" },
|
||||
s3: { label: "Amazon S3", color: "bg-orange-50 text-orange-700 border-orange-200" },
|
||||
gcp: { label: "GCS", color: "bg-green-50 text-green-700 border-green-200" },
|
||||
local: { label: "Local FS", color: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
};
|
||||
const t = typeMap[b.storage_type] ?? { label: b.storage_type, color: "bg-gray-100 text-gray-700 border-gray-200" };
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${t.color}`}>
|
||||
<Database className="w-3 h-3" />
|
||||
{b.storage_type === "azure" ? "Azure Blob" : "Local FS"}
|
||||
{t.label}
|
||||
</span>
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "azure_account",
|
||||
label: "Account / Container",
|
||||
render: (b) =>
|
||||
b.storage_type === "azure" ? (
|
||||
render: (b) => {
|
||||
if (b.storage_type === "azure") {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<p className="font-mono text-gray-800">{b.azure_account}</p>
|
||||
<p className="text-xs text-gray-500 font-mono">{b.azure_container}</p>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm italic">Local filesystem</span>
|
||||
),
|
||||
);
|
||||
}
|
||||
if (b.storage_type === "s3") {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<p className="font-mono text-gray-800">{b.s3_bucket}</p>
|
||||
<p className="text-xs text-gray-500 font-mono">{b.s3_region}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (b.storage_type === "gcp") {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<p className="font-mono text-gray-800">{b.gcs_bucket}</p>
|
||||
<p className="text-xs text-gray-500 font-mono">{b.gcs_project_id}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span className="text-gray-400 text-sm italic">Local filesystem</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "assigned_tenants_count",
|
||||
|
||||
@ -10,9 +10,12 @@
|
||||
* - User only sees basic info
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactElement } from "react";
|
||||
import { useCallback, useEffect, useState, useRef, type ReactElement } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
import * as XLSX from "xlsx";
|
||||
import { Viewer, Worker } from "@react-pdf-viewer/core";
|
||||
import "@react-pdf-viewer/core/lib/styles/index.css";
|
||||
import {
|
||||
Download,
|
||||
Share2,
|
||||
@ -78,6 +81,125 @@ function copyToClipboard(text: string): void {
|
||||
navigator.clipboard.writeText(text).catch(() => {});
|
||||
}
|
||||
|
||||
function ExcelViewer({ arrayBuffer }: { arrayBuffer: ArrayBuffer }): ReactElement {
|
||||
const [selectedSheet, setSelectedSheet] = useState<string>("");
|
||||
const [sheetsData, setSheetsData] = useState<Record<string, any[][]>>({});
|
||||
const [sheetNames, setSheetNames] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const workbook = XLSX.read(new Uint8Array(arrayBuffer), { type: "array" });
|
||||
const data: Record<string, any[][]> = {};
|
||||
workbook.SheetNames.forEach((name) => {
|
||||
const worksheet = workbook.Sheets[name];
|
||||
const rows = XLSX.utils.sheet_to_json<any[]>(worksheet, { header: 1, defval: "" });
|
||||
data[name] = rows;
|
||||
});
|
||||
setSheetNames(workbook.SheetNames);
|
||||
setSheetsData(data);
|
||||
if (workbook.SheetNames.length > 0) {
|
||||
setSelectedSheet(workbook.SheetNames[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse Excel workbook", e);
|
||||
}
|
||||
}, [arrayBuffer]);
|
||||
|
||||
if (sheetNames.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-slate-400 font-medium">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-[#084cc8] mr-2" />
|
||||
Loading spreadsheet data...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = sheetsData[selectedSheet] || [];
|
||||
const maxCols = rows.reduce((max, r) => Math.max(max, r.length), 0);
|
||||
|
||||
const getColLabel = (index: number): string => {
|
||||
let label = "";
|
||||
let temp = index;
|
||||
while (temp >= 0) {
|
||||
label = String.fromCharCode((temp % 26) + 65) + label;
|
||||
temp = Math.floor(temp / 26) - 1;
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-slate-50 overflow-hidden rounded-lg border border-slate-200">
|
||||
{/* Sheet Tabs */}
|
||||
{sheetNames.length > 1 && (
|
||||
<div className="flex gap-1.5 border-b border-slate-200 bg-white px-4 py-2 overflow-x-auto shrink-0 shadow-sm">
|
||||
{sheetNames.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => setSelectedSheet(name)}
|
||||
className={`px-3 py-1.5 text-xs font-semibold rounded-md transition-all whitespace-nowrap ${
|
||||
selectedSheet === name
|
||||
? "bg-[#084cc8] text-white shadow-sm"
|
||||
: "text-slate-600 hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid Table */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div className="inline-block min-w-full align-middle border border-slate-200 rounded-lg shadow-sm bg-white overflow-hidden">
|
||||
<table className="min-w-full border-collapse text-xs text-left">
|
||||
<thead>
|
||||
<tr className="bg-slate-100 text-slate-500 font-semibold select-none">
|
||||
<th className="border border-slate-200 w-10 text-center bg-slate-150 font-bold sticky left-0 z-10"></th>
|
||||
{Array.from({ length: maxCols }).map((_, colIdx) => (
|
||||
<th
|
||||
key={colIdx}
|
||||
className="border border-slate-200 px-3 py-2 text-center font-mono font-bold bg-slate-100 sticky top-0"
|
||||
>
|
||||
{getColLabel(colIdx)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={maxCols + 1} className="p-4 text-center text-slate-400 font-medium">
|
||||
Empty sheet
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row, rowIdx) => (
|
||||
<tr key={rowIdx} className="hover:bg-slate-50/70 border-b border-slate-100 transition-colors">
|
||||
<td className="border border-slate-200 w-10 text-center font-mono font-bold bg-slate-50 sticky left-0 z-10 text-slate-400 select-none">
|
||||
{rowIdx + 1}
|
||||
</td>
|
||||
{Array.from({ length: maxCols }).map((_, colIdx) => {
|
||||
const val = row[colIdx];
|
||||
return (
|
||||
<td
|
||||
key={colIdx}
|
||||
className="border border-slate-200 px-3 py-2 text-slate-700 whitespace-nowrap overflow-hidden text-ellipsis max-w-[200px]"
|
||||
>
|
||||
{val !== undefined && val !== null ? String(val) : ""}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Preview component
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@ -87,6 +209,33 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
|
||||
const [err, setErr] = useState(false);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [extractedHtml, setExtractedHtml] = useState<string | null>(null);
|
||||
const [docxData, setDocxData] = useState<ArrayBuffer | null>(null);
|
||||
const [excelData, setExcelData] = useState<ArrayBuffer | null>(null);
|
||||
const docxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isWord = [
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/msword",
|
||||
].includes(file.mime_type || "");
|
||||
|
||||
const isExcel = [
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-excel",
|
||||
].includes(file.mime_type || "");
|
||||
|
||||
const isPdf = file.mime_type === "application/pdf";
|
||||
|
||||
useEffect(() => {
|
||||
if (isWord && docxData && docxRef.current) {
|
||||
docxRef.current.innerHTML = "";
|
||||
import("docx-preview")
|
||||
.then(({ renderAsync }) => {
|
||||
renderAsync(docxData, docxRef.current!)
|
||||
.catch((e) => console.error("docx-preview failed", e));
|
||||
})
|
||||
.catch((e) => console.error("failed to dynamically import docx-preview", e));
|
||||
}
|
||||
}, [docxData, isWord]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadPreview = async () => {
|
||||
@ -94,35 +243,33 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
|
||||
setErr(false);
|
||||
setExtractedHtml(null);
|
||||
setPreviewUrl(undefined);
|
||||
setDocxData(null);
|
||||
setExcelData(null);
|
||||
|
||||
const isOffice = [
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.ms-powerpoint",
|
||||
].includes(file.mime_type || "");
|
||||
const isOffice = isWord || isExcel;
|
||||
|
||||
try {
|
||||
const url = await fileAttachmentService.getPreviewUrl(file.id);
|
||||
|
||||
if (isOffice) {
|
||||
try {
|
||||
const res = await fileAttachmentService.extractContent(file.id);
|
||||
if (res.success && (res.data.html || res.data.text)) {
|
||||
setExtractedHtml(res.data.html || res.data.text);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (extractionErr) {
|
||||
console.warn(
|
||||
"Content extraction failed, falling back to blob preview",
|
||||
extractionErr,
|
||||
);
|
||||
}
|
||||
}
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
|
||||
const url = await fileAttachmentService.getPreviewUrl(file.id);
|
||||
if (isWord) {
|
||||
setDocxData(arrayBuffer);
|
||||
} else if (isExcel) {
|
||||
setExcelData(arrayBuffer);
|
||||
}
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (extractionErr) {
|
||||
console.error("Client-side Office document parsing failed", extractionErr);
|
||||
setErr(true);
|
||||
}
|
||||
} else {
|
||||
setPreviewUrl(url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Preview load failed", error);
|
||||
setErr(true);
|
||||
@ -149,7 +296,7 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
if (err || (!previewUrl && !extractedHtml)) {
|
||||
if (err || (!previewUrl && !extractedHtml && !docxData && !excelData)) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-[#9aa6b2]">
|
||||
<FileText className="w-16 h-16 text-gray-200" />
|
||||
@ -204,12 +351,29 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
|
||||
className="max-w-full rounded-lg shadow transition-transform duration-200"
|
||||
/>
|
||||
</div>
|
||||
) : isPdf && previewUrl ? (
|
||||
<Worker workerUrl="https://unpkg.com/pdfjs-dist@3.11.174/build/pdf.worker.min.js">
|
||||
<div className="flex-1 overflow-hidden h-full w-full bg-slate-100 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-4xl h-full shadow-lg rounded-lg overflow-hidden bg-white">
|
||||
<Viewer fileUrl={previewUrl} />
|
||||
</div>
|
||||
</div>
|
||||
</Worker>
|
||||
) : previewUrl ? (
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
title={file.original_name}
|
||||
className="flex-1 w-full border-0 bg-white"
|
||||
/>
|
||||
) : isWord && docxData ? (
|
||||
<div className="flex-1 overflow-auto bg-white p-8">
|
||||
<div
|
||||
ref={docxRef}
|
||||
className="max-w-4xl mx-auto bg-white shadow-sm border border-gray-100 p-8 rounded-lg docx-container"
|
||||
/>
|
||||
</div>
|
||||
) : isExcel && excelData ? (
|
||||
<ExcelViewer arrayBuffer={excelData} />
|
||||
) : extractedHtml ? (
|
||||
<div className="flex-1 overflow-auto bg-white p-8">
|
||||
<div
|
||||
|
||||
@ -10,11 +10,22 @@ export interface StorageBucket {
|
||||
name: string;
|
||||
description?: string;
|
||||
owner_tenant_id: string;
|
||||
storage_type: 'azure' | 'local';
|
||||
storage_type: 'azure' | 'local' | 's3' | 'gcp';
|
||||
// Azure Blob Storage
|
||||
azure_account?: string;
|
||||
azure_container?: string;
|
||||
azure_sas_token_masked?: string;
|
||||
azure_url?: string;
|
||||
// Amazon S3
|
||||
s3_bucket?: string;
|
||||
s3_region?: string;
|
||||
s3_access_key_id?: string;
|
||||
s3_secret_access_key_masked?: string;
|
||||
// Google Cloud Storage
|
||||
gcs_bucket?: string;
|
||||
gcs_project_id?: string;
|
||||
gcs_service_account_key_masked?: string;
|
||||
// Common
|
||||
is_active: boolean;
|
||||
assigned_tenants_count?: number;
|
||||
assigned_tenants?: Array<{
|
||||
@ -46,11 +57,21 @@ export interface BucketAssignment {
|
||||
export interface CreateBucketPayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
storage_type: 'azure' | 'local';
|
||||
storage_type: 'azure' | 'local' | 's3' | 'gcp';
|
||||
// Azure
|
||||
azure_account?: string;
|
||||
azure_container?: string;
|
||||
azure_sas_token?: string;
|
||||
azure_url?: string;
|
||||
// Amazon S3
|
||||
s3_bucket?: string;
|
||||
s3_region?: string;
|
||||
s3_access_key_id?: string;
|
||||
s3_secret_access_key?: string;
|
||||
// GCP
|
||||
gcs_bucket?: string;
|
||||
gcs_project_id?: string;
|
||||
gcs_service_account_key?: string;
|
||||
}
|
||||
|
||||
export interface UpdateBucketPayload extends Partial<CreateBucketPayload> {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user