diff --git a/bun.lock b/bun.lock
index d1848ca..89d7211 100644
--- a/bun.lock
+++ b/bun.lock
@@ -52,6 +52,7 @@
"posthog-node": "^5.8.6",
"react": "19.1.0",
"react-dom": "19.1.0",
+ "react-dropzone": "^14.3.8",
"react-hook-form": "^7.62.0",
"react-icons": "^5.5.0",
"sass": "^1.93.0",
@@ -831,6 +832,8 @@
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
+ "attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="],
+
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"axe-core": ["axe-core@4.10.3", "", {}, "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg=="],
@@ -1021,6 +1024,8 @@
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
+ "file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="],
+
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="],
@@ -1371,6 +1376,8 @@
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
+ "react-dropzone": ["react-dropzone@14.3.8", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug=="],
+
"react-hook-form": ["react-hook-form@7.62.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA=="],
"react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="],
diff --git a/instrumentation-client.ts b/instrumentation-client.ts
index e56e74e..7e9963e 100644
--- a/instrumentation-client.ts
+++ b/instrumentation-client.ts
@@ -1,9 +1,9 @@
-import posthog from "posthog-js"
+import posthog from "posthog-js";
-posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
- api_host: "/ingest",
- ui_host: "https://us.posthog.com",
- defaults: '2025-05-24',
- capture_exceptions: true, // This enables capturing exceptions using Error Tracking, set to false if you don't want this
- debug: process.env.NODE_ENV === "development",
-});
+// posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
+// api_host: "/ingest",
+// ui_host: "https://us.posthog.com",
+// defaults: "2025-05-24",
+// capture_exceptions: true, // This enables capturing exceptions using Error Tracking, set to false if you don't want this
+// debug: process.env.NODE_ENV === "development",
+// });
diff --git a/next.config.ts b/next.config.ts
index 31cdd1f..501feb6 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -5,6 +5,15 @@ const nextConfig: NextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
+ images: {
+ remotePatterns: [
+ {
+ hostname: "api.post0.live",
+ protocol: "https",
+ pathname: "/**",
+ },
+ ],
+ },
async rewrites() {
return [
{
diff --git a/package.json b/package.json
index 0f66fc0..1c904e1 100644
--- a/package.json
+++ b/package.json
@@ -57,6 +57,7 @@
"posthog-node": "^5.8.6",
"react": "19.1.0",
"react-dom": "19.1.0",
+ "react-dropzone": "^14.3.8",
"react-hook-form": "^7.62.0",
"react-icons": "^5.5.0",
"sass": "^1.93.0",
diff --git a/src/app/inbox/page.tsx b/src/app/alerts/page.tsx
similarity index 58%
rename from src/app/inbox/page.tsx
rename to src/app/alerts/page.tsx
index d3d3208..9c8305e 100644
--- a/src/app/inbox/page.tsx
+++ b/src/app/alerts/page.tsx
@@ -4,21 +4,27 @@ import LoaderSection from "@/components/shared/loader-section";
import NotificationList from "@/components/shared/notification-list";
import { useInbox } from "@/hooks/data/use-inbox";
-export default function InboxPage() {
+export default function AlertsPage() {
const { data, isPending, error } = useInbox();
- console.log(data);
-
- console.log("error", error);
return (
-
Notifications
-
Stay updated with your scheduled posts and activities
+
+ Notifications
+
+
+ Stay updated with your scheduled posts and activities
+
{isPending &&
}
- {data &&
}
+ {data && (
+
+ )}
);
diff --git a/src/app/gallery/page.tsx b/src/app/gallery/page.tsx
index 41d34a2..78ffe42 100644
--- a/src/app/gallery/page.tsx
+++ b/src/app/gallery/page.tsx
@@ -1,22 +1,198 @@
"use client";
+import React, { useState, useEffect } from "react";
+import { Pagination } from "@heroui/pagination";
+import { Chip } from "@heroui/chip";
+import { Select, SelectItem } from "@heroui/select";
+import { Input } from "@heroui/input";
+
+import { Card, CardBody } from "@heroui/card";
+import { Search, Filter } from "lucide-react";
+import { AssetType } from "@/types/asset";
+import FileUploadZone from "@/components/gallery/FileUploadZone";
+import AssetGrid from "@/components/gallery/AssetGrid";
+import GallerySkeleton from "@/components/gallery/GallerySkeleton";
+import AssetEditModal from "@/components/gallery/AssetEditModal";
+import { Asset, useGalleryAssets } from "@/hooks/data/use-assets";
+
export default function GalleryPage() {
+ const [page, setPage] = useState(1);
+ const pageSize = 10;
+ const { data: paginationData, isLoading: galleryLoading } = useGalleryAssets(
+ "default",
+ {
+ page,
+ pageSize,
+ }
+ );
+
+ const [searchQuery, setSearchQuery] = useState("");
+ const [selectedType, setSelectedType] = useState("all");
+ const [editingAsset, setEditingAsset] = useState(null);
+ const [isEditModalOpen, setIsEditModalOpen] = useState(false);
+
+ const handleAssetEdit = (asset: Asset) => {
+ setEditingAsset(asset);
+ setIsEditModalOpen(true);
+ };
+
+ const handleEditModalClose = (open: boolean) => {
+ setIsEditModalOpen(open);
+ if (!open) {
+ setEditingAsset(null);
+ }
+ };
+
+ const assets = paginationData?.assets || [];
+
+ const filteredAssets = assets?.filter((asset) => {
+ const matchesSearch =
+ searchQuery === "" ||
+ asset.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ asset.description?.toLowerCase().includes(searchQuery.toLowerCase());
+
+ const matchesType = selectedType === "all" || asset.type === selectedType;
+
+ return matchesSearch && matchesType;
+ });
+
+ const handlePageChange = (newPage: number) => {
+ setPage(newPage);
+ };
+
+ useEffect(() => {
+ setPage(1);
+ }, [searchQuery, selectedType]);
+
return (
-
-
-
Gallery
-
- Manage and view your media assets in one place
+
+
+
Gallery
+
+ Manage and view your media assets
-
-
- Gallery functionality is coming soon! In the meantime, you can use
- unsplash.
-
+
+
+
+
+
+
+ {galleryLoading &&
}
+
+
+ }
+ value={searchQuery}
+ onValueChange={setSearchQuery}
+ className="flex-1"
+ size="sm"
+ />
+
+
+
+
+ {(searchQuery || selectedType !== "all") && (
+
+ {searchQuery && (
+ setSearchQuery("")}
+ size="sm"
+ >
+ Search: {searchQuery}
+
+ )}
+ {selectedType !== "all" && (
+ setSelectedType("all")}
+ size="sm"
+ >
+ Type: {selectedType}
+
+ )}
+
+ )}
+
+
+
+ {/* Pagination Controls */}
+ {paginationData &&
+ paginationData.totalPages > 1 &&
+ !searchQuery &&
+ selectedType === "all" && (
+
+ )}
+
+ {/* Results Info */}
+ {paginationData && (
+
+ {searchQuery || selectedType !== "all" ? (
+
Showing {filteredAssets.length} filtered results
+ ) : (
+
+ Showing {(page - 1) * pageSize + 1}-
+ {Math.min(page * pageSize, paginationData.totalCount)} of{" "}
+ {paginationData.totalCount} assets
+
+ )}
+
+ )}
+
+ {/* Asset Edit Modal */}
+
);
diff --git a/src/components/gallery/AssetCard.tsx b/src/components/gallery/AssetCard.tsx
new file mode 100644
index 0000000..1669fea
--- /dev/null
+++ b/src/components/gallery/AssetCard.tsx
@@ -0,0 +1,324 @@
+"use client";
+
+import React, { useState } from "react";
+import { Tooltip } from "@heroui/tooltip";
+import { Chip } from "@heroui/chip";
+import { Button } from "@heroui/button";
+import { Image } from "@heroui/image";
+import {
+ Modal,
+ ModalContent,
+ ModalBody,
+ useDisclosure,
+ ModalFooter,
+} from "@heroui/modal";
+import { Card, CardBody, CardFooter } from "@heroui/card";
+import { Download, Trash2, Edit, Video, FileImage, Eraser } from "lucide-react";
+import fetcher from "@/lib/fetcher";
+
+import { Asset } from "@/hooks/data/use-assets";
+import { getQueryClient } from "@/lib/get-query-client";
+import { addToast } from "@heroui/toast";
+
+interface AssetCardProps {
+ asset: Asset;
+ onEdit?: (asset: Asset) => void;
+ className?: string;
+ showActions?: boolean;
+}
+
+const AssetCard: React.FC
= ({
+ asset,
+ onEdit,
+ className = "",
+ showActions = true,
+}) => {
+ const [isLoading, setIsLoading] = useState(false);
+ const { isOpen, onOpen, onOpenChange } = useDisclosure();
+ const qc = getQueryClient();
+
+ const formatFileSize = (bytes?: number): string => {
+ if (!bytes) return "Unknown size";
+ const k = 1024;
+ const sizes = ["Bytes", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
+ };
+
+ const formatDate = (date: Date): string => {
+ return new Date(date).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+ };
+
+ const handleView = () => {
+ onOpen();
+ };
+
+ const handleDelete = async () => {
+ setIsLoading(true);
+ try {
+ await fetcher("DELETE", `/gallery/${asset.galleryId}/assets/${asset.id}`);
+ await qc.invalidateQueries({
+ queryKey: ["gallery-assets", "default"],
+ });
+ } catch (error: any) {
+ console.error("Delete failed:", error);
+ addToast({
+ title: "Unable to delete asset",
+ description: error?.message || "An unexpected error occurred.",
+ color: "danger",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleDownload = async () => {
+ try {
+ const response = await fetch(asset.url);
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = asset.title || asset.externalKey;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ window.URL.revokeObjectURL(url);
+ } catch (error) {
+ console.error("Download failed:", error);
+ }
+ };
+
+ const renderAssetPreview = () => {
+ if (asset.type === "IMAGE") {
+ return (
+
+ );
+ } else if (asset.type === "VIDEO") {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ };
+
+ const renderFullView = () => {
+ if (asset.type === "IMAGE") {
+ return (
+
+ );
+ } else if (asset.type === "VIDEO") {
+ return (
+
+ );
+ }
+
+ return null;
+ };
+
+ return (
+ <>
+
+
+
+
+ {renderAssetPreview()}
+
+
+
+ {asset.consumed && (
+
+ Used
+
+ )}
+ {asset.hidden && (
+
+ Hidden
+
+ )}
+
+
+
+
+
+
+
+ {asset.title || "Untitled Asset"}
+
+ {asset.description && (
+
+ {asset.description}
+
+ )}
+
+ {formatFileSize(asset.size)}
+ {formatDate(asset.createdAt)}
+
+
+
+ {showActions && (
+
+
+
+
+
+
+ {onEdit && (
+
+
+
+ )}
+
+
+
+
+
+
+ )}
+
+
+
+
+
+ {(onClose) => (
+ <>
+
+
+
+ {renderFullView()}
+
+
+
+
+ {asset.title || "Untitled Asset"}
+
+ {asset.description && (
+
+ {asset.description}
+
+ )}
+
+ Size: {formatFileSize(asset.size)}
+ Created: {formatDate(asset.createdAt)}
+ Type: {asset.type}
+
+
+
+
+
+ {showActions && (
+
+
+
+
+
+
+ {onEdit && (
+
+
+
+ )}
+
+
+
+
+
+
+ )}
+
+ >
+ )}
+
+
+ >
+ );
+};
+
+export default AssetCard;
diff --git a/src/components/gallery/AssetEditModal.tsx b/src/components/gallery/AssetEditModal.tsx
new file mode 100644
index 0000000..ccd1cc1
--- /dev/null
+++ b/src/components/gallery/AssetEditModal.tsx
@@ -0,0 +1,259 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import {
+ Modal,
+ ModalContent,
+ ModalHeader,
+ ModalBody,
+ ModalFooter,
+} from "@heroui/modal";
+import { Button } from "@heroui/button";
+import { Input } from "@heroui/input";
+import { Textarea } from "@heroui/input";
+import { Switch } from "@heroui/switch";
+import { Divider } from "@heroui/divider";
+import { addToast } from "@heroui/toast";
+import fetcher from "@/lib/fetcher";
+import { getQueryClient } from "@/lib/get-query-client";
+import { Asset } from "@/hooks/data/use-assets";
+
+interface AssetEditModalProps {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ asset: Asset | null;
+}
+
+interface UpdateAssetData {
+ title?: string;
+ description?: string;
+ useMultiple: boolean;
+ consumed: boolean;
+}
+
+const AssetEditModal: React.FC = ({
+ isOpen,
+ onOpenChange,
+ asset,
+}) => {
+ const [formData, setFormData] = useState({
+ title: "",
+ description: "",
+ useMultiple: false,
+ consumed: false,
+ });
+ const [isLoading, setIsLoading] = useState(false);
+ const [isGeneratingAI, setIsGeneratingAI] = useState(false); // need in future
+ const qc = getQueryClient();
+
+ // Reset form when asset changes
+ useEffect(() => {
+ if (asset) {
+ setFormData({
+ title: asset.title || "",
+ description: asset.description || "",
+ useMultiple: asset.useMultiple,
+ consumed: asset.consumed,
+ });
+ }
+ }, [asset]);
+
+ const handleInputChange = (field: keyof UpdateAssetData, value: any) => {
+ setFormData((prev) => ({
+ ...prev,
+ [field]: value,
+ }));
+ };
+
+ const handleSave = async () => {
+ if (!asset) return;
+
+ setIsLoading(true);
+ try {
+ await fetcher(
+ "PATCH",
+ `/gallery/${asset.galleryId}/assets/${asset.id}`,
+ {
+ body: formData,
+ }
+ );
+
+ // Invalidate and refetch gallery assets
+ await qc.invalidateQueries({
+ queryKey: ["gallery-assets"],
+ });
+
+ addToast({
+ title: "Success",
+ description: "Asset updated successfully!",
+ color: "success",
+ });
+
+ onOpenChange(false);
+ } catch (error) {
+ console.error("Failed to update asset:", error);
+ addToast({
+ title: "Error",
+ description: "Failed to update asset. Please try again.",
+ color: "danger",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleClose = () => {
+ if (!isLoading) {
+ onOpenChange(false);
+ }
+ };
+
+ if (!asset) return null;
+
+ return (
+
+
+ {(onClose) => (
+ <>
+
+ Edit Asset
+
+ Update asset details and settings
+
+
+
+
+
+
+ {asset.type === "IMAGE" ? (
+

+ ) : (
+
+ 🎥
+
+ )}
+
+
+
+ {asset.title || "Untitled"}
+
+
+ {asset.type} • {Math.round(asset.size / 1024)} KB
+
+
+ {new Date(asset.createdAt).toLocaleDateString()}
+
+
+
+
+
+
+
+
handleInputChange("title", value)}
+ variant="bordered"
+ isDisabled={isLoading || isGeneratingAI}
+ />
+
+
+
+
+
+
+
+
+ Asset Settings
+
+
+
+
+ handleInputChange("useMultiple", value)
+ }
+ isDisabled={isLoading || isGeneratingAI}
+ >
+
+
+ Allow Multiple Uses
+
+
+ This asset can be used in multiple posts
+
+
+
+
+
+ handleInputChange("consumed", value)
+ }
+ isDisabled={isLoading || isGeneratingAI}
+ color="warning"
+ >
+
+
+ Mark as Consumed
+
+
+ This asset has been used and is no longer available
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ );
+};
+
+export default AssetEditModal;
diff --git a/src/components/gallery/AssetGrid.tsx b/src/components/gallery/AssetGrid.tsx
new file mode 100644
index 0000000..c2c0b4c
--- /dev/null
+++ b/src/components/gallery/AssetGrid.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import React from "react";
+import { Card, CardBody, Skeleton } from "@heroui/react";
+import { Search, FolderOpen } from "lucide-react";
+import AssetCard from "./AssetCard";
+import { Asset } from "@/hooks/data/use-assets";
+
+interface AssetGridProps {
+ assets: Asset[];
+ onAssetEdit?: (asset: Asset) => void;
+ loading?: boolean;
+ error?: string;
+ emptyMessage?: string;
+ className?: string;
+ columns?: {
+ sm?: number;
+ md?: number;
+ lg?: number;
+ xl?: number;
+ };
+}
+
+const AssetGrid: React.FC = ({
+ assets,
+ onAssetEdit,
+ loading = false,
+ error,
+ emptyMessage = "No assets found. Upload some files to get started!",
+ className = "",
+ columns = {
+ sm: 1,
+ md: 2,
+ lg: 3,
+ xl: 4,
+ },
+}) => {
+ const getGridClasses = () => {
+ const { sm = 1, md = 2, lg = 3, xl = 4 } = columns;
+ return `grid gap-2 grid-cols-${sm} sm:grid-cols-${md} lg:grid-cols-${lg} xl:grid-cols-${xl}`;
+ };
+
+ const renderLoadingSkeleton = () => {
+ return (
+
+ {Array.from({ length: 8 }).map((_, index) => (
+
+
+
+
+
+
+ ))}
+
+ );
+ };
+
+ const renderEmptyState = () => {
+ return (
+
+
+
+
+
+ No Assets Found
+
+
{emptyMessage}
+
+ );
+ };
+
+ const renderErrorState = () => {
+ return (
+
+
+
+
+
+ Error Loading Assets
+
+
+ {error ||
+ "Something went wrong while loading your assets. Please try again."}
+
+
+ );
+ };
+
+ const renderAssets = () => {
+ return (
+
+ {assets.map((asset) => (
+
+ ))}
+
+ );
+ };
+
+ if (loading) {
+ return (
+ {renderLoadingSkeleton()}
+ );
+ }
+
+ if (error) {
+ return {renderErrorState()}
;
+ }
+
+ if (assets.length === 0) {
+ return {renderEmptyState()}
;
+ }
+
+ return (
+
+
+
+ {assets.length} {assets.length === 1 ? "asset" : "assets"} found
+
+
+
+ {renderAssets()}
+
+ );
+};
+
+export default AssetGrid;
diff --git a/src/components/gallery/FileUploadZone.tsx b/src/components/gallery/FileUploadZone.tsx
new file mode 100644
index 0000000..92d8c80
--- /dev/null
+++ b/src/components/gallery/FileUploadZone.tsx
@@ -0,0 +1,635 @@
+"use client";
+
+import React, { useCallback, useState } from "react";
+import { useDropzone } from "react-dropzone";
+import {
+ Card,
+ CardBody,
+ Button,
+ Chip,
+ addToast,
+ Progress,
+ Spinner,
+} from "@heroui/react";
+import {
+ Upload,
+ FileImage,
+ FileVideo,
+ X,
+ CheckCircle,
+ AlertCircle,
+ RotateCcw,
+} from "lucide-react";
+import fetcher from "@/lib/fetcher";
+import { getQueryClient } from "@/lib/get-query-client";
+
+interface UploadingFile {
+ file: File;
+ progress: number;
+ status: "uploading" | "success" | "error";
+ error?: string;
+ id: string;
+}
+
+interface FileUploadZoneProps {
+ onFilesAdded?: () => void;
+ galleryId?: string;
+ maxFiles?: number;
+ maxFileSize?: number; // in MB
+ acceptedTypes?: string[];
+ disabled?: boolean;
+ className?: string;
+ size?: "compact" | "sm" | "md" | "lg";
+ variant?: "full" | "compact" | "button";
+}
+
+const FileUploadZone: React.FC = ({
+ onFilesAdded,
+ maxFiles = 10,
+ maxFileSize = 100,
+ acceptedTypes = ["image/*"],
+ disabled = false,
+ galleryId = "default",
+ className = "",
+ size = "md",
+ variant = "full",
+}) => {
+ const [dragActive, setDragActive] = useState(false);
+ const [uploadingFiles, setUploadingFiles] = useState([]);
+
+ const isUploading = uploadingFiles.some((f) => f.status === "uploading");
+ const qc = getQueryClient();
+ const generateFileId = () => Math.random().toString(36).substr(2, 9);
+
+ const onUpload = async (files: File[]) => {
+ const newUploadingFiles: UploadingFile[] = files.map((file) => ({
+ file,
+ progress: 0,
+ status: "uploading" as const,
+ id: generateFileId(),
+ }));
+
+ setUploadingFiles((prev) => [...prev, ...newUploadingFiles]);
+
+ // Simulate progress for each file (replace with actual upload progress)
+ for (const uploadingFile of newUploadingFiles) {
+ try {
+ // Simulate progress updates
+ const progressInterval = setInterval(() => {
+ setUploadingFiles((prev) =>
+ prev.map((f) =>
+ f.id === uploadingFile.id && f.progress < 90
+ ? { ...f, progress: f.progress + Math.random() * 30 }
+ : f
+ )
+ );
+ }, 200);
+
+ const res = await fetcher(
+ "POST",
+ `/gallery/${galleryId}/assets/upload`,
+ {
+ raw: true,
+ files: [uploadingFile.file],
+ }
+ );
+
+ clearInterval(progressInterval);
+
+ // Update to success
+ setUploadingFiles((prev) =>
+ prev.map((f) =>
+ f.id === uploadingFile.id
+ ? { ...f, progress: 100, status: "success" as const }
+ : f
+ )
+ );
+
+ // Move to completed after a short delay
+ setTimeout(() => {
+ setUploadingFiles((prev) =>
+ prev.filter((f) => f.id !== uploadingFile.id)
+ );
+ }, 1500);
+ } catch (error) {
+ // Update to error state
+ setUploadingFiles((prev) =>
+ prev.map((f) =>
+ f.id === uploadingFile.id
+ ? {
+ ...f,
+ status: "error" as const,
+ error: (error as Error).message,
+ }
+ : f
+ )
+ );
+
+ addToast({
+ title: "Upload Error",
+ description: `Failed to upload ${uploadingFile.file.name}: ${
+ (error as Error).message
+ }`,
+ color: "danger",
+ });
+
+ console.error("Error uploading file:", error);
+ } finally {
+ qc.invalidateQueries({
+ queryKey: ["gallery-assets", galleryId],
+ });
+ }
+ }
+
+ onFilesAdded?.();
+ };
+
+ const retryUpload = (fileToRetry: UploadingFile) => {
+ setUploadingFiles((prev) => prev.filter((f) => f.id !== fileToRetry.id));
+ onUpload([fileToRetry.file]);
+ };
+
+ const removeFile = (fileId: string) => {
+ setUploadingFiles((prev) => prev.filter((f) => f.id !== fileId));
+ };
+ const sizeConfig = {
+ compact: {
+ cardPadding: "p-3",
+ iconSize: "w-5 h-5",
+ iconContainer: "p-1.5",
+ titleSize: "text-sm",
+ subtitleSize: "text-xs",
+ descriptionSize: "text-xs",
+ buttonSize: "sm" as const,
+ spacing: "space-y-1.5",
+ },
+ sm: {
+ cardPadding: "p-4",
+ iconSize: "w-6 h-6",
+ iconContainer: "p-2",
+ titleSize: "text-base",
+ subtitleSize: "text-xs",
+ descriptionSize: "text-xs",
+ buttonSize: "sm" as const,
+ spacing: "space-y-2",
+ },
+ md: {
+ cardPadding: "p-6",
+ iconSize: "w-8 h-8",
+ iconContainer: "p-3",
+ titleSize: "text-lg",
+ subtitleSize: "text-sm",
+ descriptionSize: "text-xs",
+ buttonSize: "md" as const,
+ spacing: "space-y-3",
+ },
+ lg: {
+ cardPadding: "p-8",
+ iconSize: "w-10 h-10",
+ iconContainer: "p-4",
+ titleSize: "text-xl",
+ subtitleSize: "text-base",
+ descriptionSize: "text-sm",
+ buttonSize: "lg" as const,
+ spacing: "space-y-4",
+ },
+ };
+
+ const config = sizeConfig[size];
+
+ const onDrop = useCallback(
+ (acceptedFiles: File[]) => {
+ if (acceptedFiles.length > 0) {
+ onUpload(acceptedFiles);
+ }
+ },
+ [onFilesAdded]
+ );
+
+ const onDragEnter = useCallback(() => {
+ setDragActive(true);
+ }, []);
+
+ const onDragLeave = useCallback(() => {
+ setDragActive(false);
+ }, []);
+
+ const { getRootProps, getInputProps, isDragActive, fileRejections, open } =
+ useDropzone({
+ onDrop,
+ onDragEnter,
+ onDragLeave,
+ accept: acceptedTypes.reduce((acc, type) => {
+ acc[type] = [];
+ return acc;
+ }, {} as Record),
+ maxFiles,
+ maxSize: maxFileSize * 1024 * 1024,
+ disabled: disabled || isUploading,
+ multiple: maxFiles > 1,
+ });
+
+ const getFileIcon = (file: File) => {
+ if (file.type.startsWith("image/")) {
+ return ;
+ } else if (file.type.startsWith("video/")) {
+ return ;
+ }
+ return ;
+ };
+
+ const getStatusIcon = (status: UploadingFile["status"]) => {
+ switch (status) {
+ case "uploading":
+ return ;
+ case "success":
+ return ;
+ case "error":
+ return ;
+ default:
+ return null;
+ }
+ };
+
+ const formatFileSize = (bytes: number) => {
+ if (bytes === 0) return "0 Bytes";
+ const k = 1024;
+ const sizes = ["Bytes", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
+ };
+
+ if (variant === "button") {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {isUploading && variant !== "compact" && (
+
+
+
+
+ Uploading{" "}
+ {
+ uploadingFiles.filter((f) => f.status === "uploading")
+ .length
+ }{" "}
+ file(s)...
+
+
+
+ )}
+
+
+ {isUploading ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {isDragActive
+ ? "Drop files here"
+ : isUploading
+ ? "Uploading..."
+ : variant === "compact"
+ ? "Upload files"
+ : "Upload your assets"}
+
+ {variant !== "compact" && (
+ <>
+
+ {isUploading
+ ? "Please wait while files are being uploaded"
+ : "Drag and drop files here, or click to browse"}
+
+
+ Supports images and videos up to {maxFileSize}MB
+
+ >
+ )}
+ {variant === "compact" && !isUploading && (
+
+ {isDragActive ? "Drop to upload" : `Max ${maxFileSize}MB`}
+
+ )}
+
+
+ {variant === "compact" && isUploading && (
+
+
+
+ {
+ uploadingFiles.filter((f) => f.status === "uploading")
+ .length
+ }{" "}
+ uploading
+
+
+ )}
+
+ {variant !== "compact" && (
+
+ )}
+
+
+
+
+ {/* Uploading Files Progress */}
+ {uploadingFiles.length > 0 && variant !== "compact" && (
+
+
+ Uploading Files
+
+ {uploadingFiles.map((uploadingFile) => (
+
+
+
+
+ {getFileIcon(uploadingFile.file)}
+
+
+
+
+ {uploadingFile.file.name}
+
+
+
+ {formatFileSize(uploadingFile.file.size)}
+
+ {getStatusIcon(uploadingFile.status)}
+
+
+
+ {uploadingFile.status === "uploading" && (
+
+
+
+ {Math.round(uploadingFile.progress)}% uploaded
+
+
+ )}
+
+ {uploadingFile.status === "error" && (
+
+
+ {uploadingFile.error || "Upload failed"}
+
+
}
+ onPress={() => retryUpload(uploadingFile)}
+ >
+ Retry
+
+
+ )}
+
+ {uploadingFile.status === "success" && (
+
+ Upload completed successfully
+
+ )}
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* Compact Uploading Files Progress */}
+ {uploadingFiles.length > 0 && variant === "compact" && (
+
+ {uploadingFiles.map((uploadingFile) => (
+
+
+ {getFileIcon(uploadingFile.file)}
+
+
+
+
+ {uploadingFile.file.name}
+
+
+ {uploadingFile.status === "uploading" && (
+
+ {Math.round(uploadingFile.progress)}%
+
+ )}
+ {getStatusIcon(uploadingFile.status)}
+
+
+
+ {uploadingFile.status === "uploading" && (
+
+ )}
+
+ {uploadingFile.status === "error" && (
+
+
+ {uploadingFile.error || "Upload failed"}
+
+
+
+ )}
+
+
+
+
+ ))}
+
+ )}
+
+ {fileRejections.length > 0 && variant !== "compact" && (
+
+
+
+ File Upload Issues
+
+ {fileRejections.map(({ file, errors }, index: number) => (
+
+
+
+
+ {getFileIcon(file)}
+
+
+ {file.name}
+
+
+ {formatFileSize(file.size)}
+
+
+ {errors.map((error, errorIndex) => (
+
+ • {error.message}
+
+ ))}
+
+
+
+
+ {errors.length > 1 ? `${errors.length} issues` : "Invalid"}
+
+
+
+
+ ))}
+
+
Common issues:
+
+ - File size exceeds {maxFileSize}MB limit
+ -
+ File type not supported (only {acceptedTypes.join(", ")}{" "}
+ allowed)
+
+ - Too many files selected (max {maxFiles} files)
+
+
+
+ )}
+
+ {/* Compact File Rejections */}
+ {fileRejections.length > 0 && variant === "compact" && (
+
+ {fileRejections.map(({ file, errors }, index: number) => (
+
+
+
+
+ {file.name}
+
+
+ {errors[0]?.message || "Invalid file"}
+
+
+
+ Error
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default FileUploadZone;
diff --git a/src/components/gallery/GallerySkeleton.tsx b/src/components/gallery/GallerySkeleton.tsx
new file mode 100644
index 0000000..d216645
--- /dev/null
+++ b/src/components/gallery/GallerySkeleton.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import React from "react";
+import { Card, CardBody, Skeleton } from "@heroui/react";
+
+const GallerySkeleton: React.FC = () => {
+ return (
+
+
+ {/* Filters and Search Skeleton */}
+
+
+ {/* Assets Grid Skeleton */}
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, index) => (
+
+
+
+
+
+
+ ))}
+
+
+
+ );
+};
+
+export default GallerySkeleton;
diff --git a/src/components/gallery/index.ts b/src/components/gallery/index.ts
new file mode 100644
index 0000000..6ef6a45
--- /dev/null
+++ b/src/components/gallery/index.ts
@@ -0,0 +1,5 @@
+// Export all shared components for easy imports
+export { default as FileUploadZone } from "./FileUploadZone";
+export { default as AssetCard } from "./AssetCard";
+export { default as AssetGrid } from "./AssetGrid";
+export { default as GallerySkeleton } from "./GallerySkeleton";
diff --git a/src/components/schedules/ImageSelectionZone.tsx b/src/components/schedules/ImageSelectionZone.tsx
new file mode 100644
index 0000000..3215b86
--- /dev/null
+++ b/src/components/schedules/ImageSelectionZone.tsx
@@ -0,0 +1,335 @@
+"use client";
+
+import { AssetSource } from "@/hooks/data/use-schedules";
+import { useUnsplashImages } from "@/hooks/data/use-unsplash";
+import { Card } from "@heroui/card";
+import { Input } from "@heroui/input";
+import { Spinner } from "@heroui/spinner";
+import { Tab, Tabs } from "@heroui/tabs";
+import {
+ ArrowLeft,
+ ArrowRight,
+ Search,
+ SearchIcon,
+ Trash2,
+} from "lucide-react";
+import React, { useCallback, useState } from "react";
+import { FileUploadZone } from "../gallery";
+import { Button } from "@heroui/button";
+import { useGalleryAssets } from "@/hooks/data/use-assets";
+
+interface ImageSelectionZone {
+ currentImageUrl: string | null;
+ onImageUploaded: (imageUrl: string) => void;
+ onImageRemoved: () => void;
+ isDisabled?: boolean;
+}
+
+export default function ImageSelectionZone({
+ currentImageUrl,
+ onImageUploaded,
+ onImageRemoved,
+ isDisabled = false,
+}: ImageSelectionZone) {
+ const [activeSource, setActiveSource] = useState(
+ AssetSource.NONE
+ );
+ const [unsplashQuery, setUnsplashQuery] = useState("");
+ const [galleryQuery, setGalleryQuery] = useState("");
+ const [searchQuery, setSearchQuery] = useState("technology");
+ const [page, setPage] = useState(1);
+
+ const { data: unsplashImages, isLoading: isUnsplashLoading } =
+ useUnsplashImages({ query: searchQuery, page, pageSize: 6 }, true);
+
+ const { data: galleryImages, isLoading: isGalleryLoading } = useGalleryAssets(
+ "default",
+ {
+ page,
+ pageSize: 6,
+ }
+ );
+
+ // Select an Unsplash image
+ const handleUnsplashImageSelect = useCallback(
+ (url: string) => {
+ if (isDisabled) return;
+ onImageUploaded(url);
+ },
+ [isDisabled, onImageUploaded]
+ );
+
+ // Handle tab change
+ const handleTabChange = useCallback((key: React.Key) => {
+ setActiveSource(key as AssetSource);
+ setPage(1);
+ }, []);
+
+ // Handle Unsplash search
+ const handleUnsplashSearch = useCallback(async () => {
+ setSearchQuery(unsplashQuery.trim()); // Update the search query to trigger the API call
+ }, [unsplashQuery]);
+
+ const handleGallerySearch = useCallback(async () => {
+ setSearchQuery(galleryQuery.trim()); // Update the search query to trigger the API call
+ }, [galleryQuery]);
+
+ const filteredGalleryImages =
+ galleryImages?.assets.filter((asset) =>
+ asset.title.toLowerCase().includes(galleryQuery.toLowerCase())
+ ) || [];
+
+ // Render current image
+ const renderCurrentImage = () => {
+ if (!currentImageUrl) return null;
+
+ return (
+
+

+ {!isDisabled && (
+
+ )}
+
+ );
+ };
+
+ // Render gallery tab content
+ const renderGalleryTab = () => {
+ if (isGalleryLoading) {
+ return (
+
+
+ Loading images...
+
+ );
+ }
+
+ if (galleryImages && galleryImages.assets.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ setGalleryQuery(e.target.value)}
+ placeholder="Search in gallery"
+ className="flex-1"
+ variant="faded"
+ startContent={}
+ isDisabled={isGalleryLoading || isDisabled}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ handleGallerySearch();
+ }
+ }}
+ />
+
+
+
+
+
+ {galleryImages &&
+ filteredGalleryImages.map((asset) => (
+
handleUnsplashImageSelect(asset.url)}
+ >
+
+
+ ))}
+
+ {galleryImages && galleryImages.assets.length === 0 && (
+
+ No images found. Upload some images to get started.
+
+ )}
+
+
+ {galleryImages && galleryImages.totalPages > 1 && (
+
+
+
+
+ )}
+
+ );
+ };
+
+ const renderUnsplashTab = () => {
+ return (
+
+
+ setUnsplashQuery(e.target.value)}
+ placeholder="Search Unsplash images"
+ className="flex-1"
+ variant="faded"
+ startContent={}
+ isDisabled={isUnsplashLoading || isDisabled}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ handleUnsplashSearch();
+ }
+ }}
+ />
+
+
+
+ {isUnsplashLoading ? (
+
+
+ Loading images...
+
+ ) : (
+
+ {unsplashImages &&
+ unsplashImages.map((image) => (
+
handleUnsplashImageSelect(image.urls.regular)}
+ >
+
+
+ ))}
+
+ {unsplashImages && unsplashImages.length === 0 && (
+
+ {searchQuery
+ ? `No images found for "${searchQuery}". Try a different query.`
+ : "Enter a search term to find images."}
+
+ )}
+
+ )}
+
+
+
+
+
+
+ );
+ };
+
+ // Render "none" tab content
+ const renderNoneTab = () => {
+ return (
+
+ No image will be used for this post.
+
+ );
+ };
+
+ return (
+
+ {currentImageUrl ? (
+ renderCurrentImage()
+ ) : (
+
+
+
+ {renderGalleryTab()}
+
+
+ {renderUnsplashTab()}
+
+ onImageRemoved()}
+ >
+ {renderNoneTab()}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/schedules/ImageUploadZone.tsx b/src/components/schedules/ImageUploadZone.tsx
deleted file mode 100644
index e4f9b6c..0000000
--- a/src/components/schedules/ImageUploadZone.tsx
+++ /dev/null
@@ -1,350 +0,0 @@
-/* eslint-disable @next/next/no-img-element */
-"use client";
-
-import { AssetSource } from "@/hooks/data/use-schedules";
-import { useUnsplashImages } from "@/hooks/data/use-unsplash";
-import { cn } from "@/lib/utils";
-import { Button, ButtonGroup, PressEvent } from "@heroui/button";
-import { Card } from "@heroui/card";
-import { Input } from "@heroui/input";
-import { Spinner } from "@heroui/spinner";
-import { Tab, Tabs } from "@heroui/tabs";
-import { addToast } from "@heroui/toast";
-import { ArrowLeft, ArrowRight, ImageIcon, Search, SearchIcon, Trash2, Upload } from "lucide-react";
-import React, { useCallback, useRef, useState } from "react";
-
-interface Asset {
- id: string;
- name: string;
- url: string;
- thumbnailUrl: string;
-}
-
-interface ImageUploadZoneProps {
- currentImageUrl: string | null;
- onImageUploaded: (imageUrl: string) => void;
- onImageRemoved: () => void;
- isDisabled?: boolean;
-}
-
-export default function ImageUploadZone({ currentImageUrl, onImageUploaded, onImageRemoved, isDisabled = false }: ImageUploadZoneProps) {
- const [activeSource, setActiveSource] = useState(AssetSource.NONE);
- const [isDragging, setIsDragging] = useState(false);
- const [isUploading, setIsUploading] = useState(false);
- const [unsplashQuery, setUnsplashQuery] = useState("");
- const [searchQuery, setSearchQuery] = useState("technology"); // Actual query used for API call
- const [page, setPage] = useState(1);
-
- const fileInputRef = useRef(null);
- // Mock data (replace with real API calls)
- const [isGalleryLoading, setIsGalleryLoading] = useState(false);
- const [galleryAssets, setGalleryAssets] = useState([]);
-
- const { data: unsplashImages, isLoading: isUnsplashLoading, refetch: refetchUnsplashImages } = useUnsplashImages({ query: searchQuery, page }, true);
-
- // Handle drag events
- const handleDragEnter = useCallback(
- (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- if (!isDisabled && activeSource === AssetSource.GALLERY) {
- setIsDragging(true);
- }
- },
- [isDisabled, activeSource]
- );
-
- const handleDragLeave = useCallback((e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDragging(false);
- }, []);
-
- const handleDragOver = useCallback(
- (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- if (!isDisabled && activeSource === AssetSource.GALLERY && !isDragging) {
- setIsDragging(true);
- }
- },
- [isDisabled, isDragging, activeSource]
- );
-
- // Process the uploaded file
- const processFile = useCallback(
- async (file: File) => {
- // Validate file type
- if (!file.type.startsWith("image/")) {
- addToast({
- title: "Only images are allowed",
- color: "danger",
- });
- return;
- }
-
- if (file.size > 5 * 1024 * 1024) {
- addToast({
- title: "Image size should be less than 5MB",
- color: "danger",
- });
- return;
- }
-
- try {
- setIsUploading(true);
-
- const mockUploadedUrl = URL.createObjectURL(file);
- onImageUploaded(mockUploadedUrl);
-
- addToast({
- title: "Image uploaded successfully",
- color: "success",
- });
- } catch (error) {
- console.error("Error uploading image:", error);
- addToast({
- title: error instanceof Error ? error.message : "Failed to upload image",
- color: "danger",
- });
- } finally {
- setIsUploading(false);
- setIsDragging(false);
- }
- },
- [onImageUploaded]
- );
-
- // Handle drop event
- const handleDrop = useCallback(
- (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDragging(false);
-
- if (isDisabled || activeSource !== AssetSource.GALLERY) return;
-
- if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
- const file = e.dataTransfer.files[0];
- processFile(file);
- e.dataTransfer.clearData();
- }
- },
- [isDisabled, processFile, activeSource]
- );
-
- // Handle file selection via click
- const handleFileSelect = useCallback(() => {
- if (isDisabled || activeSource !== AssetSource.GALLERY) return;
- fileInputRef.current?.click();
- }, [isDisabled, activeSource]);
-
- // Handle file input change
- const handleFileInputChange = useCallback(
- (e: React.ChangeEvent) => {
- if (e.target.files && e.target.files.length > 0) {
- const file = e.target.files[0];
- processFile(file);
-
- // Reset file input so same file can be selected again
- if (fileInputRef.current) {
- fileInputRef.current.value = "";
- }
- }
- },
- [processFile]
- );
-
- // Select a gallery image
- const handleGalleryImageSelect = useCallback(
- (url: string) => {
- if (isDisabled) return;
- onImageUploaded(url);
- },
- [isDisabled, onImageUploaded]
- );
-
- // Select an Unsplash image
- const handleUnsplashImageSelect = useCallback(
- (url: string) => {
- if (isDisabled) return;
- onImageUploaded(url);
- },
- [isDisabled, onImageUploaded]
- );
-
- // Handle tab change
- const handleTabChange = useCallback((key: React.Key) => {
- setActiveSource(key as AssetSource);
- }, []);
-
- // Handle Unsplash search
- const handleUnsplashSearch = useCallback(async () => {
- setSearchQuery(unsplashQuery.trim()); // Update the search query to trigger the API call
- }, [unsplashQuery]);
-
- // Render current image
- const renderCurrentImage = () => {
- if (!currentImageUrl) return null;
-
- return (
-
-

- {!isDisabled && (
-
- )}
-
- );
- };
-
- // Render gallery tab content
- const renderGalleryTab = () => {
- if (isGalleryLoading) {
- return (
-
-
- Loading assets...
-
- );
- }
-
- return (
-
-
- {isUploading ? (
-
- ) : (
-
-
{isDragging ? : }
-
{isDragging ? "Drop image here" : "Drag and drop an image or click to upload"}
-
- )}
-
-
-
- {galleryAssets.map((asset) => (
-
handleGalleryImageSelect(asset.url)}>
-
-
- ))}
-
- {galleryAssets.length === 0 &&
No gallery assets found. Upload images to see them here.
}
-
-
- );
- };
-
- const renderUnsplashTab = () => {
- return (
-
-
- setUnsplashQuery(e.target.value)}
- placeholder="Search Unsplash images"
- className="flex-1"
- variant="faded"
- startContent={}
- isDisabled={isUnsplashLoading || isDisabled}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- handleUnsplashSearch();
- }
- }}
- />
-
-
-
- {isUnsplashLoading ? (
-
-
- Loading images...
-
- ) : (
-
- {unsplashImages &&
- unsplashImages.map((image) => (
-
handleUnsplashImageSelect(image.urls.regular)}>
-
-
- ))}
-
- {unsplashImages && unsplashImages.length === 0 &&
{searchQuery ? `No images found for "${searchQuery}". Try a different query.` : "Enter a search term to find images."}
}
-
- )}
-
-
-
-
-
-
- );
- };
-
- // Render "none" tab content
- const renderNoneTab = () => {
- return No image will be used for this post.
;
- };
-
- return (
-
- {currentImageUrl ? (
- renderCurrentImage()
- ) : (
-
-
- {/*
- {renderGalleryTab()}
- */}
-
- {renderUnsplashTab()}
-
- onImageRemoved()}>
- {renderNoneTab()}
-
-
-
- )}
-
-
-
- );
-}
diff --git a/src/components/schedules/modal/EditPostModal.tsx b/src/components/schedules/modal/EditPostModal.tsx
index 16904fb..65baf98 100644
--- a/src/components/schedules/modal/EditPostModal.tsx
+++ b/src/components/schedules/modal/EditPostModal.tsx
@@ -13,7 +13,6 @@ import { Input } from "@heroui/input";
import { Textarea } from "@heroui/input";
import { CheckCheckIcon } from "lucide-react";
import { SchedulePost } from "@/hooks/data/use-schedules";
-import ImageUploadZone from "../ImageUploadZone";
import PlatformPreview from "../platform-previews/PlatformPreview";
import { z } from "zod";
import { Controller, useForm } from "react-hook-form";
@@ -27,6 +26,8 @@ import { Chip } from "@heroui/chip";
import { getQueryClient } from "@/lib/get-query-client";
import { TimeInput } from "@heroui/date-input";
import { Time } from "@internationalized/date";
+import ImageSelectionZone from "../ImageSelectionZone";
+
type EditPostModalProps = {
isOpen: boolean;
onClose: () => void;
@@ -258,7 +259,8 @@ export default function EditPostModal({
)}
-
Select Image (optional)
+ setValue("imageUrl", url)}
onImageRemoved={handleRemoveImage}
diff --git a/src/components/sidebar/index.tsx b/src/components/sidebar/index.tsx
index 60d2d99..4370c91 100644
--- a/src/components/sidebar/index.tsx
+++ b/src/components/sidebar/index.tsx
@@ -12,6 +12,7 @@ import {
TimerIcon,
SidebarOpenIcon,
SidebarCloseIcon,
+ BellDotIcon,
} from "lucide-react";
import { Drawer, DrawerContent } from "@heroui/drawer";
import { Button } from "@heroui/button";
@@ -53,7 +54,7 @@ const navigationData: NavSection[] = [
},
{
title: "Notification",
- items: [{ icon: Inbox, label: "Inbox", href: "/inbox" }],
+ items: [{ icon: BellDotIcon, label: "Alerts", href: "/alerts" }],
},
{
diff --git a/src/hooks/data/use-assets.ts b/src/hooks/data/use-assets.ts
index ce4076c..fd36b6d 100644
--- a/src/hooks/data/use-assets.ts
+++ b/src/hooks/data/use-assets.ts
@@ -3,21 +3,48 @@ import { useQuery } from "@tanstack/react-query";
export type Asset = {
id: string;
- name: string;
- url: string;
- thumbnailUrl: string;
+ consumed: boolean;
createdAt: Date;
+ externalKey: string;
galleryId: string;
+ hidden: boolean;
+ size: number;
+ title: string;
+ type: "IMAGE" | "VIDEO";
+ url: string;
+ useMultiple: boolean;
+ description?: string;
};
-export interface GalleryResponse {
+export interface AssetPaginationResponse {
assets: Asset[];
+ totalCount: number;
+ page: number;
+ pageSize: number;
+ totalPages: number;
}
-export function useGalleryAssets() {
- return useQuery({
- queryKey: ["gallery-assets"],
- queryFn: async () => fetcher("GET", "/gallery"),
+export function useGalleryAssets(
+ galleryId = "default",
+ filters?: {
+ page?: number;
+ pageSize?: number;
+ }
+) {
+ return useQuery({
+ queryKey: [
+ "gallery-assets",
+ galleryId,
+ filters?.page || 1,
+ filters?.pageSize || 20,
+ ],
+ queryFn: async () =>
+ fetcher(
+ "GET",
+ `/gallery/${galleryId}/assets?page=${filters?.page || 1}&pageSize=${
+ filters?.pageSize || 20
+ }`
+ ),
});
}
diff --git a/src/hooks/data/use-unsplash.ts b/src/hooks/data/use-unsplash.ts
index ccc1b9c..26e2790 100644
--- a/src/hooks/data/use-unsplash.ts
+++ b/src/hooks/data/use-unsplash.ts
@@ -3,13 +3,29 @@ import { useQuery } from "@tanstack/react-query";
interface UnsplashImages {
id: string;
- urls: { raw: string; full: string; regular: string; small: string; thumb: string };
+ urls: {
+ raw: string;
+ full: string;
+ regular: string;
+ small: string;
+ thumb: string;
+ };
}
-export function useUnsplashImages(body: { query: string; page: number }, enabled: boolean = true) {
+export function useUnsplashImages(
+ body: { query: string; page: number; pageSize: number },
+ enabled: boolean = true
+) {
return useQuery({
- queryKey: ["unsplash", body.page, body.query],
- queryFn: async () => fetcher("GET", "/unsplash", { params: { query: body.query, page: String(body.page) } }),
+ queryKey: ["unsplash", body.page, body.query, body.pageSize],
+ queryFn: async () =>
+ fetcher("GET", "/unsplash", {
+ params: {
+ query: body.query,
+ page: String(body.page),
+ pageSize: String(body.pageSize),
+ },
+ }),
enabled: enabled && body.query.trim().length > 0,
});
}
diff --git a/src/lib/fetcher.ts b/src/lib/fetcher.ts
index f8e2f67..c1debe2 100644
--- a/src/lib/fetcher.ts
+++ b/src/lib/fetcher.ts
@@ -9,6 +9,9 @@ export default async function fetcher(
params?: Record;
body?: Record;
headers?: Record;
+ raw?: boolean;
+ files?: File[] | FileList;
+ formData?: FormData;
}
): Promise {
const apiVersion = "v1";
@@ -16,25 +19,66 @@ export default async function fetcher(
const url = `${APP_CONFIG.API_BASE}/${apiVersion}${endpoint}`;
const parsedUrl = new URL(url);
- const { searchParams, params, body, headers } = others || {};
+ const { searchParams, params, body, headers, files, formData } = others || {};
if (searchParams) {
Object.entries(searchParams).forEach(([key, value]) => {
parsedUrl.searchParams.append(key, value);
});
}
- const config = {
+ // Handle file uploads
+ let requestData: any;
+ let isFileUpload = false;
+
+ if (formData) {
+ requestData = formData;
+ isFileUpload = true;
+ } else if (files && files.length > 0) {
+ requestData = new FormData();
+
+ // Add files to FormData
+ Array.from(files).forEach((file) => {
+ requestData.append("files", file);
+ });
+
+ // Add other body data to FormData if present
+ if (body) {
+ Object.entries(body).forEach(([key, value]) => {
+ requestData.append(key, String(value));
+ });
+ }
+
+ isFileUpload = true;
+ } else {
+ requestData = body ? JSON.stringify(body) : undefined;
+ }
+
+ let config = {
method,
url: parsedUrl.toString(),
- headers: {
- "Content-Type": "application/json",
- ...headers,
- },
+ headers: {},
withCredentials: true as const,
- data: body ? JSON.stringify(body) : undefined,
+ data: requestData,
params,
};
+ // Set headers based on request type
+ if (headers) {
+ config.headers = { ...config.headers, ...headers };
+ }
+
+ if (!others?.raw) {
+ if (isFileUpload) {
+ // For file uploads, let the browser set the Content-Type with boundary
+ // Don't manually set Content-Type for multipart/form-data
+ } else {
+ config.headers = {
+ ...config.headers,
+ "Content-Type": "application/json",
+ };
+ }
+ }
+
try {
const response = await axios(config);
return response.data.data as T;
diff --git a/src/types/asset.ts b/src/types/asset.ts
new file mode 100644
index 0000000..f412413
--- /dev/null
+++ b/src/types/asset.ts
@@ -0,0 +1,37 @@
+export enum AssetType {
+ IMAGE = "IMAGE",
+ VIDEO = "VIDEO",
+}
+
+export interface Asset {
+ id: string;
+ title?: string;
+ url: string;
+ externalKey: string;
+ size?: number;
+ useMultiple: boolean;
+ consumed: boolean;
+ createdAt: Date;
+ description?: string;
+ hidden: boolean;
+ type: AssetType;
+ galleryId: string;
+}
+
+export interface Gallery {
+ id: string;
+ assets: Asset[];
+}
+
+export interface FileUploadResult {
+ success: boolean;
+ asset?: Asset;
+ error?: string;
+}
+
+export interface FileUploadProgress {
+ file: File;
+ progress: number;
+ uploading: boolean;
+ error?: string;
+}
diff --git a/tailwind.config.js b/tailwind.config.js
index 457a122..d878cfd 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -7,7 +7,7 @@ module.exports = {
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}",
- "./node_modules/@heroui/theme/dist/components/(alert|avatar|badge|breadcrumbs|button|card|chip|date-picker|divider|drawer|dropdown|form|image|input|link|modal|popover|progress|select|skeleton|spacer|spinner|toggle|tabs|toast|user|ripple|calendar|date-input|menu|listbox|scroll-shadow).js",
+ "./node_modules/@heroui/theme/dist/components/(alert|avatar|badge|breadcrumbs|button|card|chip|date-picker|divider|drawer|dropdown|form|image|input|link|modal|pagination|popover|progress|select|skeleton|spacer|spinner|toggle|tabs|toast|user|ripple|calendar|date-input|menu|listbox|scroll-shadow).js",
],
darkMode: "class",
plugins: [