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": {
|
"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": {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user