diff --git a/app/(api)/_actions/media/findMediaItem.ts b/app/(api)/_actions/media/findMediaItem.ts new file mode 100644 index 0000000..7b528e1 --- /dev/null +++ b/app/(api)/_actions/media/findMediaItem.ts @@ -0,0 +1,11 @@ +'use server'; + +import { findMediaItem, findMediaItems } from '@datalib/media/findMediaItem'; + +export async function FindMediaItem(id: string) { + return findMediaItem(id); +} + +export async function FindMediaItems(query: object = {}) { + return findMediaItems(query); +} \ No newline at end of file diff --git a/app/(api)/_datalib/_services/Products.ts b/app/(api)/_datalib/_services/Products.ts index 8c5d6a7..9e2aed8 100644 --- a/app/(api)/_datalib/_services/Products.ts +++ b/app/(api)/_datalib/_services/Products.ts @@ -6,7 +6,6 @@ export default class Products { // CREATE static async create(input: ProductInventoryInput) { const { productInput, inventoryInput } = input; - console.log(input); const { name, price, diff --git a/app/(api)/_datalib/media/findMediaItem.ts b/app/(api)/_datalib/media/findMediaItem.ts new file mode 100644 index 0000000..8767459 --- /dev/null +++ b/app/(api)/_datalib/media/findMediaItem.ts @@ -0,0 +1,42 @@ +import { ObjectId } from 'mongodb'; +import { getDatabase } from '@utils/mongodb/mongoClient.mjs'; +import { HttpError, NotFoundError } from '@utils/response/Errors'; + +export async function findMediaItem(id: string) { + try { + const db = await getDatabase(); + const objectId = ObjectId.createFromHexString(id); + + const mediaItem = await db.collection('media').findOne({ + _id: objectId, + }); + + if (!mediaItem) { + throw new NotFoundError(`Media item with id: ${id} not found.`); + } + + return { ok: true, body: mediaItem, error: null }; + } catch (error) { + const e = error as HttpError; + return { + ok: false, + body: null, + error: e.message || 'Internal Server Error', + }; + } +} + +export async function findMediaItems(query: object = {}) { + try { + const db = await getDatabase(); + const mediaItems = await db.collection('media').find(query).toArray(); + return { ok: true, body: mediaItems, error: null }; + } catch (error) { + const e = error as HttpError; + return { + ok: false, + body: null, + error: e.message || 'Internal Server Error', + }; + } +} \ No newline at end of file diff --git a/app/(pages)/_contexts/ContentFormContext.tsx b/app/(pages)/_contexts/ContentFormContext.tsx new file mode 100644 index 0000000..b424a6b --- /dev/null +++ b/app/(pages)/_contexts/ContentFormContext.tsx @@ -0,0 +1,61 @@ +'use client'; +import { useState, createContext } from 'react'; + +interface ContentFormContextValue { + content_type: string; + id: string | null; + data: { [key: string]: any }; + updateField: (field_name: string, value: any) => void; + setData: (value: any) => void; + setId: (value: string) => void; +} + +export type { ContentFormContextValue }; + +export const ContentFormContext = createContext({ + content_type: '', + id: null, + data: {}, + updateField: (_, __) => {}, + setData: (_) => {}, + setId: (_) => {}, +}); + +interface ContentFormContextProviderProps { + content_type: string; + id?: string | null; + initialValue?: object; + children: React.ReactNode; +} + +export default function ContentFormContextProvider({ + content_type, + id = null, + initialValue = {}, + children, +}: ContentFormContextProviderProps) { + const [data, setData] = useState(initialValue); + const [idVar, setId] = useState(id); + + const updateField = (field_name: string, value: any) => { + setData((prev) => ({ + ...prev, + [field_name]: value, + })); + }; + + const value = { + content_type, + id: idVar, + data, + updateField, + setData, + setId, + }; + + return ( + + {children} + + ); +} diff --git a/app/(pages)/_contexts/ContentWindowContext.tsx b/app/(pages)/_contexts/ContentWindowContext.tsx new file mode 100644 index 0000000..9ba3dc3 --- /dev/null +++ b/app/(pages)/_contexts/ContentWindowContext.tsx @@ -0,0 +1,11 @@ +import { createContext, createRef } from 'react'; + +interface ContentWindowContextProviderValue { + contentWindowRef: React.Ref | null; +} + +export type { ContentWindowContextProviderValue }; + +export const ContentWindowContext = createContext({ + contentWindowRef: createRef(), +}); diff --git a/app/(pages)/_contexts/FilterContext.tsx b/app/(pages)/_contexts/FilterContext.tsx new file mode 100644 index 0000000..f147c34 --- /dev/null +++ b/app/(pages)/_contexts/FilterContext.tsx @@ -0,0 +1,57 @@ +'use client'; +import { createContext, useState, useCallback } from 'react'; + +interface FilterContextValue { + filters: string[]; + search: string; + setSearch: React.Dispatch>; + setFilters: React.Dispatch>; + applyFilters: (_: object[]) => object[]; +} + +export type { FilterContextValue }; + +export const FilterContext = createContext({ + filters: [], + search: '', + setSearch: () => {}, + setFilters: () => {}, + applyFilters: (_: object[]) => [], +}); + +export default function FilterContextProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [filters, setFilters] = useState([]); + const [search, setSearch] = useState(''); + + const applyFilters = useCallback( + (data: object[]) => { + const hasValueContaining = (obj: any, searchStr: string): any => { + const values = Object.values(obj); + if (typeof obj === 'string') { + return obj.toLowerCase().includes(searchStr.toLowerCase()); + } + + return values.some((value: any) => + hasValueContaining(value, searchStr) + ); + }; + return data.filter((item) => hasValueContaining(item, search)); + }, + [search] + ); + + const value = { + filters, + search, + setFilters, + setSearch, + applyFilters, + }; + return ( + {children} + ); +} diff --git a/app/(pages)/_contexts/SelectContext.tsx b/app/(pages)/_contexts/SelectContext.tsx new file mode 100644 index 0000000..28e1b55 --- /dev/null +++ b/app/(pages)/_contexts/SelectContext.tsx @@ -0,0 +1,57 @@ +'use client'; +import { createContext, useState, useCallback } from 'react'; + +interface SelectContextValue { + selectMode: boolean; + toggleSelectMode: () => void; + selectedIds: { [key: string]: any }; + toggleId: (id: string, data?: any) => void; + resetSelectedIds: () => void; +} + +export type { SelectContextValue }; + +export const SelectContext = createContext({ + selectMode: true, + toggleSelectMode: () => {}, + selectedIds: {}, + toggleId: (_: string, __: any) => {}, + resetSelectedIds: () => {}, +}); + +export default function SelectContextProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [selectMode, setSelectMode] = useState(false); + const [selectedIds, setSelectedIds] = useState<{ [key: string]: boolean }>( + {} + ); + const toggleSelectMode = useCallback(() => { + setSelectMode((prev) => !prev); + setSelectedIds({}); + }, [setSelectMode]); + + const resetSelectedIds = () => { + setSelectedIds({}); + }; + + const toggleId = (id: string, data: any = null) => { + setSelectedIds({ + ...selectedIds, + [id]: selectedIds?.[id] ? null : data || true, + }); + }; + + const value = { + selectMode, + toggleSelectMode, + selectedIds, + toggleId, + resetSelectedIds, + }; + return ( + {children} + ); +} diff --git a/app/(pages)/_hooks/useContentFormContext.ts b/app/(pages)/_hooks/useContentFormContext.ts new file mode 100644 index 0000000..1313134 --- /dev/null +++ b/app/(pages)/_hooks/useContentFormContext.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react'; +import { ContentFormContext } from '@contexts/ContentFormContext'; + +export default function useContentFormContext() { + const context = useContext(ContentFormContext); + if (!context) { + throw new Error( + 'useContentFormContext must be used within an ContentFormContextProvider' + ); + } + return context; +} diff --git a/app/(pages)/_hooks/useMedia.ts b/app/(pages)/_hooks/useMedia.ts new file mode 100644 index 0000000..36ccacc --- /dev/null +++ b/app/(pages)/_hooks/useMedia.ts @@ -0,0 +1,23 @@ +import MediaItem from '../_types/media/MediaItem'; +import { useState, useEffect } from 'react'; +import { FindMediaItems } from '@app/(api)/_actions/media/findMediaItem'; + +export default function useMedia() { + const [loading, setLoading] = useState(true); + const [data, setData] = useState([]); + const [error, setError] = useState(''); + useEffect(() => { + const fetchMedia = async () => { + const res = await FindMediaItems(); + if (res.ok) { + setData(res.body); + } else { + setError(res.error || ''); + } + setLoading(false); + }; + fetchMedia(); + }, []); + + return { loading, data, error }; +} diff --git a/app/(pages)/_hooks/useSelectContext.ts b/app/(pages)/_hooks/useSelectContext.ts new file mode 100644 index 0000000..5cd45db --- /dev/null +++ b/app/(pages)/_hooks/useSelectContext.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react'; +import { SelectContext } from '@contexts/SelectContext'; + +export default function useSelectContext() { + const context = useContext(SelectContext); + if (!context) { + throw new Error( + 'useSelectContext must be used within an SelectContextProvider' + ); + } + return context; +} diff --git a/app/(pages)/_hooks/useUploadMedia.ts b/app/(pages)/_hooks/useUploadMedia.ts new file mode 100644 index 0000000..1d9c53b --- /dev/null +++ b/app/(pages)/_hooks/useUploadMedia.ts @@ -0,0 +1,5 @@ +import MediaItem from '@typeDefs/media/MediaItem'; + +export default function useUploadMedia(data: MediaItem) { + return data; +} \ No newline at end of file diff --git a/app/(pages)/_types/media/MediaItem.ts b/app/(pages)/_types/media/MediaItem.ts new file mode 100644 index 0000000..f499bae --- /dev/null +++ b/app/(pages)/_types/media/MediaItem.ts @@ -0,0 +1,17 @@ +interface MediaItem { + _id: string | null; + cloudinary_id: string | null; + name: string; + type: string; + format: string; + src: string; + alt?: string; + size: number; + width: number | null; + height: number | null; + _created_at?: string | null; + _last_modified?: string | null; + } + + export default MediaItem; + \ No newline at end of file diff --git a/app/(pages)/_utils/convertFileToMediaItem.ts b/app/(pages)/_utils/convertFileToMediaItem.ts new file mode 100644 index 0000000..fd9f1c8 --- /dev/null +++ b/app/(pages)/_utils/convertFileToMediaItem.ts @@ -0,0 +1,15 @@ +import MediaItem from '@typeDefs/media/MediaItem'; +export default function convertFileToMediaItem(file: File): MediaItem { + const [fileType, fileFormat] = file.type.split('/'); + return { + _id: null, + cloudinary_id: null, + name: file.name, + type: fileType, + format: fileFormat, + src: URL.createObjectURL(file), + size: file.size, + width: null, + height: null, + }; +} diff --git a/app/(pages)/_utils/uploadMediaItem.ts b/app/(pages)/_utils/uploadMediaItem.ts new file mode 100644 index 0000000..26a1e4f --- /dev/null +++ b/app/(pages)/_utils/uploadMediaItem.ts @@ -0,0 +1,76 @@ +import GenerateCloudinarySignature from '@actions/cloudinary/generateCloudinarySignature'; +import { CreateMediaItem } from '@actions/media/createMediaItem'; +import HttpError from '@utils/response/HttpError'; +import MediaItem from '@typeDefs/media/MediaItem'; +import DeleteCloudinaryObject from '@actions/cloudinary/deleteCloudinaryItem'; + +export default async function uploadMediaItem(mediaItem: MediaItem) { + try { + const cloudinaryType = getCloudinaryType(mediaItem.type); + const fileRes = await fetch(mediaItem.src); + const file = await fileRes.blob(); + + const requestParams = {}; + const signatureRes = await GenerateCloudinarySignature(requestParams); + if (!signatureRes.ok) { + throw new HttpError(signatureRes.error || 'Internal Server Error'); + } + + const uploadBody = new FormData(); + Object.entries(signatureRes.body?.requestParams || {}).forEach( + ([key, value]) => { + uploadBody.append(key, (value || '').toString()); + } + ); + + uploadBody.append('file', file); + const uploadRes = await fetch( + `${signatureRes.body?.cloudUrl}/${cloudinaryType}/upload`, + { + method: 'POST', + body: uploadBody, + } + ); + + const uploadData = await uploadRes.json(); + + if (uploadData.error) { + throw new HttpError(uploadData.error.message); + } + + const updatedMediaItem = { + ...mediaItem, + cloudinary_id: uploadData.public_id, + src: uploadData.secure_url, + height: uploadData.height || null, + width: uploadData.width || null, + }; + + const { _id: _, ...creationBody } = updatedMediaItem; + + const creationRes = await CreateMediaItem(creationBody); + if (!creationRes.ok) { + const deleteStatus = await DeleteCloudinaryObject( + updatedMediaItem.cloudinary_id, + getCloudinaryType(updatedMediaItem.type) + ); + const errorMsg = `${creationRes.error}${ + deleteStatus.ok ? '' : `\n${deleteStatus.error}` + }`; + throw new Error(errorMsg); + } + + return creationRes; + } catch (e) { + const err = e as HttpError; + return { ok: false, body: null, error: err.message }; + } +} + +function getCloudinaryType(type: string) { + if (type === 'video') { + return type; + } else { + return 'image'; + } +} \ No newline at end of file diff --git a/app/(pages)/uploaded-media/_components/MediaCard/ImagePreview.tsx b/app/(pages)/uploaded-media/_components/MediaCard/ImagePreview.tsx new file mode 100644 index 0000000..e5f06ce --- /dev/null +++ b/app/(pages)/uploaded-media/_components/MediaCard/ImagePreview.tsx @@ -0,0 +1,18 @@ +import Image from 'next/image'; + +interface ImagePreviewProps { + src: string; + alt: string; +} + +export default function ImagePreview({ src, alt }: ImagePreviewProps) { + return ( + {alt} + ); +} diff --git a/app/(pages)/uploaded-media/_components/MediaCard/MediaCard.module.scss b/app/(pages)/uploaded-media/_components/MediaCard/MediaCard.module.scss new file mode 100644 index 0000000..7e33c37 --- /dev/null +++ b/app/(pages)/uploaded-media/_components/MediaCard/MediaCard.module.scss @@ -0,0 +1,67 @@ +.container { + display: flex; + flex-direction: column; + padding: var(--spacer); + gap: var(--small-spacer); + text-align: left; + border-radius: var(--c-radius); + box-shadow: var(--b-shadow); + background-color: var(--background-light); + + min-width: 324px; + width: calc(calc(100% - 5 * var(--medium-spacer)) / 3); + max-width: 424px; + height: min-content; + cursor: pointer; +} + +.top_row { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--small-spacer); + + h2 { + font-size: 1.5rem; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + text-wrap: nowrap; + } + + .checkbox_container { + height: 1.5rem; + aspect-ratio: 1; + border-radius: 4px; + border: solid 1px black; + overflow: hidden; + flex-shrink: 0; + + .checkbox_internals { + position: relative; + background-color: var(--primary); + display: flex; + height: 100%; + width: 100%; + } + } +} + +.last_edited { + font-size: 0.875rem; + color: var(--text-dark-gray); +} + +.kebab_button { + .kebab { + font-size: 1.5rem; + } +} + +.media_container { + width: 100%; + aspect-ratio: 1.52; + position: relative; + overflow: hidden; + border-radius: var(--b-radius); +} diff --git a/app/(pages)/uploaded-media/_components/MediaCard/MediaCard.tsx b/app/(pages)/uploaded-media/_components/MediaCard/MediaCard.tsx new file mode 100644 index 0000000..0573da2 --- /dev/null +++ b/app/(pages)/uploaded-media/_components/MediaCard/MediaCard.tsx @@ -0,0 +1,50 @@ +'use client'; +import styles from './MediaCard.module.scss'; +import ImagePreview from './ImagePreview'; +import MediaItem from '@typeDefs/media/MediaItem'; +import useSelectContext from '@hooks/useSelectContext'; +import checkMark from '@public/content/[content_type]/check.svg'; +import Image from 'next/image'; + +interface Props { + mediaItem: MediaItem; +} + +export default function MediaCard({ mediaItem }: Props) { + const { src, alt, type, name, format } = mediaItem; + const { selectedIds, toggleId, selectMode } = useSelectContext(); + + const preview = (() => { + switch (type) { + case 'image': + return ; + default: + return format === 'pdf' ? ( + + ) : ( +
{`No preview for media type: ${type}`}
+ ); + } + })(); + + return ( +
toggleId(mediaItem._id || '', mediaItem)} + > +
+ {selectMode && ( +
+ {selectedIds[mediaItem._id || ''] && ( +
+ checkmark +
+ )} +
+ )} +

{name}

+
+
{preview}
+
+ ); +} diff --git a/app/(pages)/uploaded-media/_components/MediaHeader/MediaHeader.module.scss b/app/(pages)/uploaded-media/_components/MediaHeader/MediaHeader.module.scss new file mode 100644 index 0000000..41b6060 --- /dev/null +++ b/app/(pages)/uploaded-media/_components/MediaHeader/MediaHeader.module.scss @@ -0,0 +1,78 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--spacer); + padding: 0 var(--medium-spacer); + + > div { + display: flex; + flex-direction: row; + } +} + +.top_row { + justify-content: space-between; + align-items: center; + > h1 { + font-size: 3rem; + font-weight: 700; + } + + .top_right { + display: flex; + flex-direction: row; + gap: var(--small-spacer); + } +} + +.bot_row { + display: flex; + flex-direction: row; + gap: var(--small-spacer); +} + +.select_button_container { + display: flex; + flex-direction: row; + gap: var(--spacer); + align-items: center; + padding: var(--small-spacer) var(--spacer); + background-color: var(--background-light); + border-radius: var(--b-radius); + box-shadow: var(-b-shadow); + + .select_button_icon { + height: 24px; + aspect-ratio: 1; + border-radius: 4px; + border: solid 1px black; + } + + > p { + color: var(--primary); + font-size: 1.375rem; + font-weight: 500; + } +} + +.trash_button_container { + padding: var(--small-spacer) var(--spacer); + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: var(--tiny-spacer); + background-color: var(--red); + border-radius: var(--b-radius); + + > h4 { + font-size: 1.375rem; + font-weight: 500; + color: var(--text-light); + } + + .trash_button_trash_icon { + height: 100%; + width: auto; + } +} diff --git a/app/(pages)/uploaded-media/_components/MediaHeader/MediaHeader.tsx b/app/(pages)/uploaded-media/_components/MediaHeader/MediaHeader.tsx new file mode 100644 index 0000000..472d4e3 --- /dev/null +++ b/app/(pages)/uploaded-media/_components/MediaHeader/MediaHeader.tsx @@ -0,0 +1,61 @@ +'use client'; +import useSelectContext from '@hooks/useSelectContext'; +//import Image from "next/image"; +//import { DeleteMediaItem } from '@actions/media/deleteMediaItem'; +import styles from './MediaHeader.module.scss'; + +//import trash from "@public/content/[content_type]/trash.svg"; + +export default function MediaHeader() { + const { selectMode, toggleSelectMode, selectedIds } = useSelectContext(); + + // const handleTrash = async () => { + // const deleteIds = Object.entries(selectedIds) + // .map(([id, toDelete]) => (toDelete && id) as string) + // .filter(Boolean); + + // if (deleteIds.length === 0) { + // alert('No items selected to trash'); + // } + + // const res = await Promise.all(deleteIds.map((id) => DeleteMediaItem(id))); + + // const ok = res.every(({ ok }) => ok); + // if (ok) { + // alert('Everything worked!'); + // toggleSelectMode(); + // } else { + // alert('Something went wrong!'); + // } + // }; + + return ( +
+
+

Uploaded Media

+
+ + {/* {selectMode && ( + + )} */} +
+
+
+ ); +} diff --git a/app/(pages)/uploaded-media/_components/MediaItemField/MediaItemField.module.scss b/app/(pages)/uploaded-media/_components/MediaItemField/MediaItemField.module.scss new file mode 100644 index 0000000..e69de29 diff --git a/app/(pages)/uploaded-media/_components/MediaItemField/MediaItemField.tsx b/app/(pages)/uploaded-media/_components/MediaItemField/MediaItemField.tsx new file mode 100644 index 0000000..6e82151 --- /dev/null +++ b/app/(pages)/uploaded-media/_components/MediaItemField/MediaItemField.tsx @@ -0,0 +1,13 @@ +import styles from './MediaItemField.module.scss'; + +interface MediaItemFieldProps { + name: string; + initial_value?: any; +} + +export default function MediaItemField({ + name: _, + initial_value: __, +}: MediaItemFieldProps) { + return
MediaItemField
; +} diff --git a/app/(pages)/uploaded-media/_components/MediaListField/MediaList.module.scss b/app/(pages)/uploaded-media/_components/MediaListField/MediaList.module.scss new file mode 100644 index 0000000..1221145 --- /dev/null +++ b/app/(pages)/uploaded-media/_components/MediaListField/MediaList.module.scss @@ -0,0 +1,95 @@ +@use "media"; + +.container { + display: flex; + flex-direction: column; + gap: var(--small-spacer); +} + +.card_container { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: var(--spacer) var(--medium-spacer); + border-radius: var(--b-radius); + box-shadow: var(--b-shadow); + cursor: grab; +} + +.image_section_left { + display: flex; + flex-direction: row; + gap: var(--large-spacer); + align-items: center; + + .drag_icon { + height: 1.5rem; + width: auto; + } + + .index { + font-size: 1.375rem; + font-weight: 300; + } + + .preview { + border-radius: var(--b-radius); + object-fit: cover; + object-position: center center; + height: 100%; + aspect-ratio: calc(3 / 2); + } + + .name_block { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + + .name { + font-size: 1.25rem; + font-weight: 700; + } + + .upload_status { + color: var(--text-dark-gray); + font-family: var(--font-dm-sans); + font-size: 1rem; + font-weight: 700; + } + } +} + +.image_section_right { + display: flex; + flex-direction: row; + gap: var(--large-spacer); + align-items: center; + + .replace { + font-family: var(--font-dm-sans); + font-size: 1.375rem; + cursor: pointer; + font-weight: 500; + } + + .size { + font-family: var(--font-dm-sans); + font-size: 1.25rem; + } + + .delete { + height: 1.625rem; + width: auto; + cursor: pointer; + } +} + +.dragging { + opacity: 0.5; +} + +.dragover { + border: 2px dashed var(--text-dark); +} diff --git a/app/(pages)/uploaded-media/_components/MediaListField/MediaList.tsx b/app/(pages)/uploaded-media/_components/MediaListField/MediaList.tsx new file mode 100644 index 0000000..abc6a54 --- /dev/null +++ b/app/(pages)/uploaded-media/_components/MediaListField/MediaList.tsx @@ -0,0 +1,139 @@ +'use client'; +import { useState, DragEvent } from 'react'; + +import useContentFormContext from '@hooks/useContentFormContext'; +import Image from 'next/image'; +import styles from './MediaList.module.scss'; +import dragIcon from '@public/content/form/drag-icon.png'; +import deleteIcon from '@public/content/form/delete.png'; +import MediaItem from '@typeDefs/media/MediaItem'; +import convertFileToMediaItem from '.app/(pages)/_utils/ConvertFileToMediaItem'; + +interface MediaListProps { + field_name: string; +} + +export default function MediaList({ field_name }: MediaListProps) { + const { data, updateField } = useContentFormContext(); + + const [draggedIndex, setDraggedIndex] = useState(-1); + const [newIndex, setNewIndex] = useState(-1); + + const formatFileSize = (size: number) => { + if (size === 0) return '0 Bytes'; + const units = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const unitIndex = Math.floor(Math.log(size) / Math.log(1024)); + const unitValue = Math.pow(1024, unitIndex); + const unitLabel = units[unitIndex]; + const formattedSize = (size / unitValue).toFixed(unitIndex === 0 ? 0 : 2); + return `${formattedSize} ${unitLabel}`; + }; + + const handleDelete = (index: number) => { + const updatedFiles = [...data[field_name]]; + updatedFiles.splice(index, 1); + updateField(field_name, updatedFiles); + }; + + const handleReplace = (index: number) => { + const input = document.createElement('input'); + input.type = 'file'; + input.addEventListener('change', (e) => { + const newFile = (e.target as HTMLInputElement).files?.[0]; + if (newFile) { + const updatedFiles = [...data[field_name]]; + const updatedFile = convertFileToMediaItem(newFile); + updatedFiles[index] = updatedFile; + updateField(field_name, updatedFiles); + } + }); + input.click(); + }; + + const handleDragStart = ( + e: DragEvent, + draggedIndex: number + ) => { + setDraggedIndex(draggedIndex); + }; + + const handleDragOver = (e: DragEvent, overIndex: number) => { + e.preventDefault(); + if (draggedIndex !== -1 && draggedIndex !== overIndex) { + setNewIndex(overIndex); + } + }; + + const handleDragLeave = () => { + setNewIndex(-1); + }; + + const handleDragEnd = () => { + if (draggedIndex !== -1 && newIndex !== -1) { + const updatedFiles = [...data[field_name]]; + const [draggedItem] = updatedFiles.splice(draggedIndex, 1); + updatedFiles.splice(newIndex, 0, draggedItem); + updateField(field_name, updatedFiles); + } + setDraggedIndex(-1); + setNewIndex(-1); + }; + + return ( +
+ {data[field_name].length === 0 ? ( +
No images/videos uploaded yet
+ ) : ( + data[field_name].map((file: MediaItem, index: number) => ( +
handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDragEnd={handleDragEnd} + > +
+ draggable icon +

#{index + 1}

+ {file.name} +
+

{file.name}

+

+ {file.cloudinary_id ? 'Uploaded to cloud' : ' '} +

+
+
+
+

{formatFileSize(file.size)}

+
handleReplace(index)} + > + Replace Image +
+ delete icon handleDelete(index)} + /> +
+
+ )) + )} +
+ ); +} diff --git a/app/(pages)/uploaded-media/_components/MediaListField/MediaListField.module.scss b/app/(pages)/uploaded-media/_components/MediaListField/MediaListField.module.scss new file mode 100644 index 0000000..705750b --- /dev/null +++ b/app/(pages)/uploaded-media/_components/MediaListField/MediaListField.module.scss @@ -0,0 +1,5 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--medium-spacer); +} diff --git a/app/(pages)/uploaded-media/_components/MediaListField/MediaListField.tsx b/app/(pages)/uploaded-media/_components/MediaListField/MediaListField.tsx new file mode 100644 index 0000000..b2d4937 --- /dev/null +++ b/app/(pages)/uploaded-media/_components/MediaListField/MediaListField.tsx @@ -0,0 +1,21 @@ +import styles from './MediaListField.module.scss'; + +import MediaList from './MediaList'; +import MediaSelector from '../MediaSelector/MediaSelector'; + +interface MediaItemFieldProps { + field_name: string; + initial_value?: string[]; +} + +export default function MediaListField({ + field_name, + initial_value: _, +}: MediaItemFieldProps) { + return ( +
+ + +
+ ); +} diff --git a/app/(pages)/uploaded-media/_components/MediaSelector/MediaFromUpload.module.scss b/app/(pages)/uploaded-media/_components/MediaSelector/MediaFromUpload.module.scss new file mode 100644 index 0000000..3401efc --- /dev/null +++ b/app/(pages)/uploaded-media/_components/MediaSelector/MediaFromUpload.module.scss @@ -0,0 +1,11 @@ +.container { + border: 1px dashed var(--text-dark); + border-radius: var(--b-radius); + padding: var(--large-spacer) var(--medium-spacer); + text-align: center; + cursor: pointer; + + > input { + display: none; + } +} diff --git a/app/(pages)/uploaded-media/_components/MediaSelector/MediaFromUpload.tsx b/app/(pages)/uploaded-media/_components/MediaSelector/MediaFromUpload.tsx new file mode 100644 index 0000000..2a70edf --- /dev/null +++ b/app/(pages)/uploaded-media/_components/MediaSelector/MediaFromUpload.tsx @@ -0,0 +1,81 @@ +'use client'; +import { useRef, DragEvent, ChangeEvent } from 'react'; +import Image from 'next/image'; +import styles from './MediaFromUpload.module.scss'; + +import uploadIcon from '@public/content/form/upload.png'; +import uploadMediaItem from '@utils/uploadMediaItem'; //backend function from vinay/ kiran + +interface MediaFromUploadProps { + onInput: (files: FileList) => void; +} + +export default function MediaFromUpload({ onInput }: MediaFromUploadProps) { + const fileInputRef = useRef(null); + + const handleDrop = async (e: DragEvent) => { + e.preventDefault(); + const droppedFiles = e.dataTransfer.files; + //onInput(droppedFiles); + await processFiles(droppedFiles); + }; + + const handleFileChange = async (e: ChangeEvent) => { + const inputFiles = e.target.files; + //onInput(inputFiles ?? new FileList()); + if (inputFiles) { + await processFiles(inputFiles); + } + e.target.value = ''; + }; + + //new function + const processFiles = async (files: FileList) => { + const uploadedMediaItems = []; + + for (const file of Array.from(files)) { + const mediaItem = { + type: file.type.startsWith('video') ? 'video' : 'image', + src: URL.createObjectURL(file), // Temporary preview URL + }; + + const response = await uploadMediaItem(mediaItem); //call backend function + if (response.ok) { + uploadedMediaItems.push(response.body); + } + } + + onInput(uploadedMediaItems); // Calls the function with new media items + }; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + }; + + const handleUploadClick = () => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + return ( +
+ upload icon +

+ Upload a File or Drag and Drop +

+ +
+ ); +} diff --git a/app/(pages)/uploaded-media/_components/MediaSelector/MediaSelector.module.scss b/app/(pages)/uploaded-media/_components/MediaSelector/MediaSelector.module.scss new file mode 100644 index 0000000..6ec2144 --- /dev/null +++ b/app/(pages)/uploaded-media/_components/MediaSelector/MediaSelector.module.scss @@ -0,0 +1,29 @@ +@use "media"; + +.container { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: var(--small-spacer); + h4 { + font-weight: 400; + } +} + +.or_container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: var(--tiny-spacer); + @include media.tablet { + flex-direction: row; + } +} + +.line { + border-left: 1px solid var(--text-dark); + height: 25px; + @include media.tablet { + display: none; + } +} diff --git a/app/(pages)/uploaded-media/_components/MediaSelector/MediaSelector.tsx b/app/(pages)/uploaded-media/_components/MediaSelector/MediaSelector.tsx new file mode 100644 index 0000000..b432a72 --- /dev/null +++ b/app/(pages)/uploaded-media/_components/MediaSelector/MediaSelector.tsx @@ -0,0 +1,41 @@ +'use client'; +import styles from './MediaSelector.module.scss'; +import MediaFromUpload from './MediaFromUpload'; +import useContentFormContext from '@hooks/useContentFormContext'; +import convertFileToMediaItem from '@utils/convertFileToMediaItem'; +//import SelectContextProvider from "@app/(pages)/_contexts/SelectContext"; + +interface MediaSelectorProps { + field_name: string; +} + +export default function MediaSelector({ field_name }: MediaSelectorProps) { + const { data, updateField } = useContentFormContext(); + + const onInput = (files: FileList) => { + const updatedFieldValue = [ + ...data[field_name], + ...Array.from(files).map(convertFileToMediaItem), + ]; + updateField(field_name, updatedFieldValue); + }; + + return ( +
+ +
+ ); + /*return ( +
+ +
+
+

or

+
+
+ + + +
+ );*/ +} diff --git a/app/(pages)/uploaded-media/_components/UploadedMediaPopup/UploadedMediaPopup.module.scss b/app/(pages)/uploaded-media/_components/UploadedMediaPopup/UploadedMediaPopup.module.scss new file mode 100644 index 0000000..4ceb5a7 --- /dev/null +++ b/app/(pages)/uploaded-media/_components/UploadedMediaPopup/UploadedMediaPopup.module.scss @@ -0,0 +1,65 @@ +.container { + position: fixed; + display: flex; + flex-direction: column; + display: none; + background-color: var(--background-primary); + overflow-y: scroll; + padding-bottom: var(--enormous-spacer); + + &.visible { + display: flex; + } +} + +.header { + display: flex; + flex-direction: column; + padding: 0 var(--medium-spacer); + gap: var(--spacer); + + .title { + font-size: 3rem; + font-weight: 700; + } +} + +.data_container { + display: flex; + flex-direction: column; + margin: var(--medium-spacer) 0; + gap: var(--medium-spacer); +} + +.button_container { + display: flex; + flex-direction: row; + gap: var(--spacer); + justify-content: center; + margin-top: var(--medium-spacer); + + .attach, + .exit_button { + border-radius: 8px; + box-shadow: 0px 4px 35px 0px rgba(60, 37, 126, 0.4); + display: flex; + padding: 20px 36px; + justify-content: center; + align-items: center; + color: #fff; + font-family: var(--font-dm-sans); + font-size: 1.5rem; + font-weight: 500; + color: var(--text-light); + } + + .attach { + background: var(--primary); + width: 480px; + } + + .exit_button { + background: var(--red); + padding: 20px 58px; + } +} diff --git a/app/(pages)/uploaded-media/_components/UploadedMediaPopup/UploadedMediaPopup.tsx b/app/(pages)/uploaded-media/_components/UploadedMediaPopup/UploadedMediaPopup.tsx new file mode 100644 index 0000000..5388c4a --- /dev/null +++ b/app/(pages)/uploaded-media/_components/UploadedMediaPopup/UploadedMediaPopup.tsx @@ -0,0 +1,67 @@ +'use client'; +import styles from './UploadedMediaPopup.module.scss'; +import useSelectContext from '@app/(pages)/_hooks/useSelectContext'; +import FilterContextProvider from '@app/(pages)/_contexts/FilterContext'; +//import MediaCard from '../MediaCard/MediaCard'; +import useMedia from '@app/(pages)/_hooks/useMedia'; +//import MediaItem from '@app/_types/media/MediaItem'; + +interface UploadedMediaPopupProps { + fieldName: string; +} + +export default function UploadedMediaPopup({ + fieldName, +}: UploadedMediaPopupProps) { + const position = { + top: `${top}px`, + left: `${left}px`, + bottom: `${window.innerHeight - bottom}px`, + right: `${window.innerWidth - right}px`, + }; + + const { selectMode, toggleSelectMode, selectedIds } = useSelectContext(); + const { loading, data: mediaData, error } = useMedia(); + + if (loading) { + return 'loading...'; + } + + if (error) { + return error; + } + + /*const data_list = mediaData.map((mediaItem: MediaItem) => { + return ; + });*/ + + const attachMedia = () => { + const selectedMedia = Object.values(selectedIds).filter(Boolean); + const updatedFieldValue = [...data[fieldName], ...selectedMedia]; + updateField(fieldName, updatedFieldValue); + toggleSelectMode(); + }; + + return ( +
+ +
+
+

Uploaded Media

+
+
+
+
+ + +
+
+ ); +} diff --git a/app/(pages)/uploaded-media/page.module.scss b/app/(pages)/uploaded-media/page.module.scss new file mode 100644 index 0000000..b120c1d --- /dev/null +++ b/app/(pages)/uploaded-media/page.module.scss @@ -0,0 +1,8 @@ +@use "media"; + +.container { + display: flex; + flex-direction: column; + margin: var(--medium-spacer) 0; + gap: var(--medium-spacer); +} diff --git a/app/(pages)/uploaded-media/page.tsx b/app/(pages)/uploaded-media/page.tsx new file mode 100644 index 0000000..12e1c2b --- /dev/null +++ b/app/(pages)/uploaded-media/page.tsx @@ -0,0 +1,32 @@ +'use server'; +import styles from './page.module.scss'; + +import MediaCard from '@componentsmedia/MediaCard/MediaCard'; +import MediaHeader from '@componentsmedia/MediaHeader/MediaHeader'; +import ContentSection from '@componentsmedia/ContentSection/ContentSection'; +import SelectContextProvider from '@contexts/SelectContext'; +import FilterContextProvider from '@contexts/FilterContext'; +import { findMediaItems } from '@datalib/media/findMediaItem'; //no media folder under ./app/(api)/_datalib and no findMediaItem function yet +import MediaItem from '@app/(pages)/_types/media/MediaItem'; + +export default async function MediaPage() { + const res = JSON.parse(JSON.stringify(await findMediaItems())); + if (!res.ok) { + return 'Error fetching Media data'; + } + + const data_list = res.body.map((mediaItem: MediaItem) => { + return ; + }); + + return ( + + +
+ + {data_list} +
+
+
+ ); +} diff --git a/public/content/[content_type]/add.svg b/public/content/[content_type]/add.svg new file mode 100644 index 0000000..513f0d0 --- /dev/null +++ b/public/content/[content_type]/add.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/content/[content_type]/check.svg b/public/content/[content_type]/check.svg new file mode 100644 index 0000000..83cde5e --- /dev/null +++ b/public/content/[content_type]/check.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/content/[content_type]/trash.svg b/public/content/[content_type]/trash.svg new file mode 100644 index 0000000..0f352cf --- /dev/null +++ b/public/content/[content_type]/trash.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/content/form/back-button.png b/public/content/form/back-button.png new file mode 100644 index 0000000..083c870 Binary files /dev/null and b/public/content/form/back-button.png differ diff --git a/public/content/form/bookmark.png b/public/content/form/bookmark.png new file mode 100644 index 0000000..2bc40d4 Binary files /dev/null and b/public/content/form/bookmark.png differ diff --git a/public/content/form/delete.png b/public/content/form/delete.png new file mode 100644 index 0000000..676dc9e Binary files /dev/null and b/public/content/form/delete.png differ diff --git a/public/content/form/drag-icon.png b/public/content/form/drag-icon.png new file mode 100644 index 0000000..b697554 Binary files /dev/null and b/public/content/form/drag-icon.png differ diff --git a/public/content/form/upload.png b/public/content/form/upload.png new file mode 100644 index 0000000..50ae7b8 Binary files /dev/null and b/public/content/form/upload.png differ diff --git a/tsconfig.json b/tsconfig.json index 527c9c5..10e9d79 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,12 +20,16 @@ } ], "paths": { + "@app/*": ["./app/*"], + "@public/*": ["./public/*"], "@globals/*": ["./app/(pages)/_globals/*"], "@components/*": ["./app/(pages)/_components/*"], + "@componentsmedia/*": ["./app/(pages)/uploaded-media/_components"], "@data/*": ["./app/(pages)/_data/*"], "@hooks/*": ["./app/(pages)/_hooks/*"], + "@types/*": ["./app/(pages)/_types/*"], "@contexts/*": ["./app/(pages)/_contexts/*"], - "@utils/*": ["./app/(api)/_utils/*"], + "@utils/*": ["./app/(pages)/_utils/*"], "@pageUtils/*": ["./app/(pages)/_utils/*"], "@actions/*": ["./app/(api)/_actions/*"], "@graphql/*": ["./app/(pages)/_graphql/*"],