feat: add support for Amazon S3 and Google Cloud Storage buckets and install document preview dependencies

This commit is contained in:
Yashwin 2026-07-07 16:02:09 +05:30
parent 099b6423ef
commit 80f68d28bd
5 changed files with 1306 additions and 105 deletions

929
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@react-pdf-viewer/core": "^3.12.0",
"@reduxjs/toolkit": "^2.11.2", "@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tiptap/extension-color": "^3.20.4", "@tiptap/extension-color": "^3.20.4",
@ -28,7 +29,9 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"docx-preview": "^0.3.7",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"pdfjs-dist": "^3.11.174",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hook-form": "^7.71.1", "react-hook-form": "^7.71.1",
@ -40,6 +43,7 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"xlsx": "^0.18.5",
"zod": "^4.3.5" "zod": "^4.3.5"
}, },
"devDependencies": { "devDependencies": {

View File

@ -54,53 +54,66 @@ const getBucketSchema = (isEdit: boolean) =>
.min(1, "Bucket name is required") .min(1, "Bucket name is required")
.max(255, "Maximum 255 characters allowed"), .max(255, "Maximum 255 characters allowed"),
description: z.string().optional().or(z.literal("")), 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", error: "Storage type is required",
}), }),
// Azure
azure_account: z.string().optional().or(z.literal("")), azure_account: z.string().optional().or(z.literal("")),
azure_container: z.string().optional().or(z.literal("")), azure_container: z.string().optional().or(z.literal("")),
azure_sas_token: z.string().optional().or(z.literal("")), azure_sas_token: z.string().optional().or(z.literal("")),
azure_url: 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) => { .superRefine((data, ctx) => {
if (data.storage_type === "azure") { if (data.storage_type === "azure") {
if (!data.azure_account || data.azure_account.trim() === "") { if (!data.azure_account || data.azure_account.trim() === "") {
ctx.addIssue({ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["azure_account"], message: "Storage account is required for Azure" });
code: z.ZodIssueCode.custom,
path: ["azure_account"],
message: "Storage account is required for Azure",
});
} }
if (!data.azure_container || data.azure_container.trim() === "") { if (!data.azure_container || data.azure_container.trim() === "") {
ctx.addIssue({ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["azure_container"], message: "Container name is required for Azure" });
code: z.ZodIssueCode.custom,
path: ["azure_container"],
message: "Container name is required for Azure",
});
} }
if (!data.azure_url || data.azure_url.trim() === "") { if (!data.azure_url || data.azure_url.trim() === "") {
ctx.addIssue({ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["azure_url"], message: "Container URL is required for Azure" });
code: z.ZodIssueCode.custom,
path: ["azure_url"],
message: "Container URL is required for Azure",
});
} else { } else {
try { try { new URL(data.azure_url); } catch {
new URL(data.azure_url); ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["azure_url"], message: "Please enter a valid 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() === "")) { if (!isEdit && (!data.azure_sas_token || data.azure_sas_token.trim() === "")) {
ctx.addIssue({ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["azure_sas_token"], message: "SAS token is required for Azure" });
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_container: "",
azure_sas_token: "", azure_sas_token: "",
azure_url: "", 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, storage_type: bucket.storage_type,
azure_account: bucket.azure_account || "", azure_account: bucket.azure_account || "",
azure_container: bucket.azure_container || "", azure_container: bucket.azure_container || "",
azure_sas_token: "", // never pre-fill the masked token azure_sas_token: "",
azure_url: bucket.azure_url || "", 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 { } else {
reset({ reset({
@ -157,6 +184,13 @@ const BucketFormModal = ({ isOpen, onClose, onSuccess, bucket }: BucketFormProps
azure_container: "", azure_container: "",
azure_sas_token: "", azure_sas_token: "",
azure_url: "", azure_url: "",
s3_bucket: "",
s3_region: "",
s3_access_key_id: "",
s3_secret_access_key: "",
gcs_bucket: "",
gcs_project_id: "",
gcs_service_account_key: "",
}); });
} }
clearErrors(); clearErrors();
@ -167,9 +201,11 @@ const BucketFormModal = ({ isOpen, onClose, onSuccess, bucket }: BucketFormProps
setSaving(true); setSaving(true);
try { try {
if (isEdit && bucket) { if (isEdit && bucket) {
// Don't send empty SAS token on edit (means "don't change it")
const payload = { ...data }; 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.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); await storageBucketService.update(bucket.id, payload as any);
showToast.success("Bucket updated successfully"); showToast.success("Bucket updated successfully");
} else { } else {
@ -218,10 +254,12 @@ const BucketFormModal = ({ isOpen, onClose, onSuccess, bucket }: BucketFormProps
placeholder="Select Storage Type" placeholder="Select Storage Type"
options={[ options={[
{ value: "azure", label: "Azure Blob Storage" }, { value: "azure", label: "Azure Blob Storage" },
{ value: "s3", label: "Amazon S3" },
{ value: "gcp", label: "Google Cloud Storage (GCS)" },
{ value: "local", label: "Local Filesystem" }, { value: "local", label: "Local Filesystem" },
]} ]}
value={storageTypeValue} 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} error={errors.storage_type?.message}
/> />
@ -270,6 +308,86 @@ const BucketFormModal = ({ isOpen, onClose, onSuccess, bucket }: BucketFormProps
</div> </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 */} {/* Actions */}
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100"> <div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
<button <button
@ -476,25 +594,52 @@ const StorageBucketsPage = () => {
{ {
key: "storage_type", key: "storage_type",
label: "Type", label: "Type",
render: (b) => ( 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"> 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" /> <Database className="w-3 h-3" />
{b.storage_type === "azure" ? "Azure Blob" : "Local FS"} {t.label}
</span> </span>
), );
},
}, },
{ {
key: "azure_account", key: "azure_account",
label: "Account / Container", label: "Account / Container",
render: (b) => render: (b) => {
b.storage_type === "azure" ? ( if (b.storage_type === "azure") {
return (
<div className="text-sm"> <div className="text-sm">
<p className="font-mono text-gray-800">{b.azure_account}</p> <p className="font-mono text-gray-800">{b.azure_account}</p>
<p className="text-xs text-gray-500 font-mono">{b.azure_container}</p> <p className="text-xs text-gray-500 font-mono">{b.azure_container}</p>
</div> </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", key: "assigned_tenants_count",

View File

@ -10,9 +10,12 @@
* - User only sees basic info * - 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 { useNavigate, useParams } from "react-router-dom";
import { useSelector } from "react-redux"; 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 { import {
Download, Download,
Share2, Share2,
@ -78,6 +81,125 @@ function copyToClipboard(text: string): void {
navigator.clipboard.writeText(text).catch(() => {}); 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 // Preview component
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@ -87,6 +209,33 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
const [err, setErr] = useState(false); const [err, setErr] = useState(false);
const [zoom, setZoom] = useState(1); const [zoom, setZoom] = useState(1);
const [extractedHtml, setExtractedHtml] = useState<string | null>(null); 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(() => { useEffect(() => {
const loadPreview = async () => { const loadPreview = async () => {
@ -94,35 +243,33 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
setErr(false); setErr(false);
setExtractedHtml(null); setExtractedHtml(null);
setPreviewUrl(undefined); setPreviewUrl(undefined);
setDocxData(null);
setExcelData(null);
const isOffice = [ const isOffice = isWord || isExcel;
"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 || "");
try { try {
const url = await fileAttachmentService.getPreviewUrl(file.id);
if (isOffice) { if (isOffice) {
try { try {
const res = await fileAttachmentService.extractContent(file.id); const response = await fetch(url);
if (res.success && (res.data.html || res.data.text)) { const blob = await response.blob();
setExtractedHtml(res.data.html || res.data.text); const arrayBuffer = await blob.arrayBuffer();
setLoading(false);
return;
}
} catch (extractionErr) {
console.warn(
"Content extraction failed, falling back to blob preview",
extractionErr,
);
}
}
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); setPreviewUrl(url);
}
} catch (error) { } catch (error) {
console.error("Preview load failed", error); console.error("Preview load failed", error);
setErr(true); setErr(true);
@ -149,7 +296,7 @@ function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
); );
} }
if (err || (!previewUrl && !extractedHtml)) { if (err || (!previewUrl && !extractedHtml && !docxData && !excelData)) {
return ( return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-[#9aa6b2]"> <div className="flex-1 flex flex-col items-center justify-center gap-3 text-[#9aa6b2]">
<FileText className="w-16 h-16 text-gray-200" /> <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" className="max-w-full rounded-lg shadow transition-transform duration-200"
/> />
</div> </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 ? ( ) : previewUrl ? (
<iframe <iframe
src={previewUrl} src={previewUrl}
title={file.original_name} title={file.original_name}
className="flex-1 w-full border-0 bg-white" 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 ? ( ) : extractedHtml ? (
<div className="flex-1 overflow-auto bg-white p-8"> <div className="flex-1 overflow-auto bg-white p-8">
<div <div

View File

@ -10,11 +10,22 @@ export interface StorageBucket {
name: string; name: string;
description?: string; description?: string;
owner_tenant_id: string; owner_tenant_id: string;
storage_type: 'azure' | 'local'; storage_type: 'azure' | 'local' | 's3' | 'gcp';
// Azure Blob Storage
azure_account?: string; azure_account?: string;
azure_container?: string; azure_container?: string;
azure_sas_token_masked?: string; azure_sas_token_masked?: string;
azure_url?: 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; is_active: boolean;
assigned_tenants_count?: number; assigned_tenants_count?: number;
assigned_tenants?: Array<{ assigned_tenants?: Array<{
@ -46,11 +57,21 @@ export interface BucketAssignment {
export interface CreateBucketPayload { export interface CreateBucketPayload {
name: string; name: string;
description?: string; description?: string;
storage_type: 'azure' | 'local'; storage_type: 'azure' | 'local' | 's3' | 'gcp';
// Azure
azure_account?: string; azure_account?: string;
azure_container?: string; azure_container?: string;
azure_sas_token?: string; azure_sas_token?: string;
azure_url?: 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> { export interface UpdateBucketPayload extends Partial<CreateBucketPayload> {