From 5df297fdeb3ecdbd9b5f83254f5d014edcac70c1 Mon Sep 17 00:00:00 2001 From: Priyanshu Verma Date: Tue, 23 Sep 2025 12:31:25 +0000 Subject: [PATCH] feat: Image uploading and gallery feature --- bun.lock | 7 + instrumentation-client.ts | 16 +- next.config.ts | 9 + package.json | 1 + src/app/{inbox => alerts}/page.tsx | 20 +- src/app/gallery/page.tsx | 196 +++++- src/components/gallery/AssetCard.tsx | 324 +++++++++ src/components/gallery/AssetEditModal.tsx | 259 +++++++ src/components/gallery/AssetGrid.tsx | 143 ++++ src/components/gallery/FileUploadZone.tsx | 635 ++++++++++++++++++ src/components/gallery/GallerySkeleton.tsx | 54 ++ src/components/gallery/index.ts | 5 + .../schedules/ImageSelectionZone.tsx | 335 +++++++++ src/components/schedules/ImageUploadZone.tsx | 350 ---------- .../schedules/modal/EditPostModal.tsx | 6 +- src/components/sidebar/index.tsx | 3 +- src/hooks/data/use-assets.ts | 43 +- src/hooks/data/use-unsplash.ts | 24 +- src/lib/fetcher.ts | 58 +- src/types/asset.ts | 37 + tailwind.config.js | 2 +- 21 files changed, 2129 insertions(+), 398 deletions(-) rename src/app/{inbox => alerts}/page.tsx (58%) create mode 100644 src/components/gallery/AssetCard.tsx create mode 100644 src/components/gallery/AssetEditModal.tsx create mode 100644 src/components/gallery/AssetGrid.tsx create mode 100644 src/components/gallery/FileUploadZone.tsx create mode 100644 src/components/gallery/GallerySkeleton.tsx create mode 100644 src/components/gallery/index.ts create mode 100644 src/components/schedules/ImageSelectionZone.tsx delete mode 100644 src/components/schedules/ImageUploadZone.tsx create mode 100644 src/types/asset.ts 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 ( + {asset.title + ); + } else if (asset.type === "VIDEO") { + return ( +
+
+ ); + } + + return ( +
+ +
+ ); + }; + + const renderFullView = () => { + if (asset.type === "IMAGE") { + return ( + {asset.title + ); + } 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 + ) : ( +
+ 🎥 +
+ )} +
+
+

+ {asset.title || "Untitled"} +

+

+ {asset.type} • {Math.round(asset.size / 1024)} KB +

+

+ {new Date(asset.createdAt).toLocaleDateString()} +

+
+
+ + + +
+ handleInputChange("title", value)} + variant="bordered" + isDisabled={isLoading || isGeneratingAI} + /> + +
+