diff --git a/app/(api)/_actions/cloudinary/deleteCloudinaryItem.ts b/app/(api)/_actions/cloudinary/deleteCloudinaryItem.ts new file mode 100644 index 0000000..ec624ab --- /dev/null +++ b/app/(api)/_actions/cloudinary/deleteCloudinaryItem.ts @@ -0,0 +1,32 @@ +'use server'; +import HttpError from '@utils/response/HttpError'; +import { v2 as cloudinary } from 'cloudinary'; + +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}); + +export default async function DeleteCloudinaryObject( + cloudinary_id: string, + type: string +) { + try { + const result = await cloudinary.uploader.destroy(cloudinary_id, { + resource_type: type, + }); + return { + ok: true, + body: result, + error: null, + }; + } catch (e) { + const err = e as HttpError; + return { + ok: false, + body: null, + error: err.message, + }; + } +} diff --git a/app/(api)/_actions/cloudinary/generateCloudinarySignature.ts b/app/(api)/_actions/cloudinary/generateCloudinarySignature.ts new file mode 100644 index 0000000..cd47177 --- /dev/null +++ b/app/(api)/_actions/cloudinary/generateCloudinarySignature.ts @@ -0,0 +1,32 @@ +'use server'; +import HttpError from '@utils/response/HttpError'; +import { v2 as cloudinary } from 'cloudinary'; + +export default async function GenerateCloudinarySignature( + requestParams: object +) { + try { + const timestamp = Math.round(new Date().getTime() / 1000); + const signature = cloudinary.utils.api_sign_request( + { + timestamp, + ...requestParams, + }, + process.env.CLOUDINARY_API_SECRET as string + ); + + const body = { + cloudUrl: `https://api.cloudinary.com/v1_1/${process.env.CLOUDINARY_NAME}`, + requestParams: { + api_key: process.env.CLOUDINARY_API_KEY, + timestamp, + signature, + ...requestParams, + }, + }; + return { ok: true, body, error: null }; + } catch (e) { + const error = e as HttpError; + return { ok: false, body: null, error: error.message }; + } +} diff --git a/app/(api)/_actions/media/createMediaItem.ts b/app/(api)/_actions/media/createMediaItem.ts new file mode 100644 index 0000000..6b01d96 --- /dev/null +++ b/app/(api)/_actions/media/createMediaItem.ts @@ -0,0 +1,11 @@ +'use server'; +import { createMediaItem } from '@datalib/media/createMediaItem'; +import { revalidatePath } from 'next/cache'; +// import WithCallback from '@app/(api)/_utils/callback/withCallback'; +import WithCallback from '@utils/callback/withCallback'; + +export const CreateMediaItem = WithCallback(async (body: object) => { + const createMediaItemRes = await createMediaItem(body); + revalidatePath('/uploaded-media'); + return createMediaItemRes; +}); diff --git a/app/(api)/_actions/media/deleteMediaItem.ts b/app/(api)/_actions/media/deleteMediaItem.ts new file mode 100644 index 0000000..69f8051 --- /dev/null +++ b/app/(api)/_actions/media/deleteMediaItem.ts @@ -0,0 +1,11 @@ +'use server'; +import { deleteMediaItem } from '@datalib/media/deleteMediaItem'; +import { revalidatePath } from 'next/cache'; +// import WithCallback from '@app/(api)/_utils/callback/withCallback'; +import WithCallback from '@utils/callback/withCallback'; + +export const DeleteMediaItem = WithCallback(async (id: string) => { + const deleteMediaRes = await deleteMediaItem(id); + revalidatePath('/uploaded-media'); + return deleteMediaRes; +}); diff --git a/app/(api)/_actions/media/findMediaItem.ts b/app/(api)/_actions/media/findMediaItem.ts new file mode 100644 index 0000000..82c9952 --- /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); +} diff --git a/app/(api)/_actions/media/updateMediaItems.ts b/app/(api)/_actions/media/updateMediaItems.ts new file mode 100644 index 0000000..9fe209d --- /dev/null +++ b/app/(api)/_actions/media/updateMediaItems.ts @@ -0,0 +1,11 @@ +'use server'; +import { createMediaItem } from '@datalib/media/createMediaItem'; +import { revalidatePath } from 'next/cache'; +// import WithCallback from '@app/(api)/_utils/callback/withCallback'; +import WithCallback from '@utils/callback/withCallback'; +// import WithCallback from '@utils/callback/withCallback'; +export const CreateMediaItem = WithCallback(async (body: object) => { + const createMediaItemRes = await createMediaItem(body); + revalidatePath('/uploaded-media'); + return createMediaItemRes; +}); diff --git a/app/(api)/_datalib/_services/Media.ts b/app/(api)/_datalib/_services/Media.ts new file mode 100644 index 0000000..dd3ced4 --- /dev/null +++ b/app/(api)/_datalib/_services/Media.ts @@ -0,0 +1,87 @@ +import prisma from '../_prisma/client'; +import { MediaInput } from '@datatypes/Media'; +export default class MediaService { + // CREATE + static async create(input: MediaInput) { + const media = await prisma.media.create({ + data: { + cloudinary_id: input.cloudinary_id, + name: input.name, + type: input.type, + format: input.format, + src: input.src, + alt: input.alt || null, + size: input.size, + width: input.width, + height: input.height, + created_at: input.created_at || new Date().toISOString(), + last_modified: input.last_modified || new Date().toISOString(), + }, + }); + return media; + } + + // READ + static async find(id: string) { + return prisma.media.findUnique({ + where: { + id, + }, + }); + } + + // READ + static async findMany(ids: string[]) { + if (!ids || ids.length === 0) { + return prisma.media.findMany(); + } + + return prisma.media.findMany({ + where: { + id: { + in: ids, + }, + }, + }); + } + + // UPDATE + static async update(id: string, input: Partial) { + try { + const media = await prisma.media.update({ + where: { + id, + }, + data: { + cloudinary_id: input.cloudinary_id || undefined, + name: input.name || undefined, + type: input.type || undefined, + format: input.format || undefined, + src: input.src || undefined, + alt: input.alt || null, // If `alt` is not provided, set it to null + size: input.size || undefined, + width: input.width || undefined, + height: input.height || undefined, + last_modified: new Date().toISOString(), // Automatically set `last_modified` to current time + }, + }); + return media; + } catch (e) { + return null; // If update fails, return null + } + } + + // DELETE + static async delete(id: string) { + try { + await prisma.media.delete({ + where: { + id, + }, + }); + return true; + } catch (e) { + return false; + } + } +} diff --git a/app/(api)/_types/Media.ts b/app/(api)/_types/Media.ts new file mode 100644 index 0000000..0d75fad --- /dev/null +++ b/app/(api)/_types/Media.ts @@ -0,0 +1,30 @@ +interface Media { + _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 type MediaInput = { + 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 Media; diff --git a/app/(api)/_utils/callback/withCallback.ts b/app/(api)/_utils/callback/withCallback.ts new file mode 100644 index 0000000..5a262ae --- /dev/null +++ b/app/(api)/_utils/callback/withCallback.ts @@ -0,0 +1,10 @@ +// import callbacks from '@app/_utils/callbacks'; + +// type CallbackFunction = (...args: any[]) => any; +// export default function WithCallback(func: (..._: any) => any) { +// return async (...args: any) => { +// const res = await func(...args); +// callbacks.onUpdate(); +// return res; +// }; +// } diff --git a/app/(api)/_utils/request/getQueries.ts b/app/(api)/_utils/request/getQueries.ts new file mode 100644 index 0000000..d3528f8 --- /dev/null +++ b/app/(api)/_utils/request/getQueries.ts @@ -0,0 +1,62 @@ +import { NextRequest } from 'next/server'; +import prisma from '@datalib/_prisma/client'; +// import { DMMF } from '@prisma/client/runtime'; + +function typeCast(value: string, type: string) { + switch (type) { + case 'Int': + return isNaN(+value) ? value : +value; + case 'String': + return value; + case 'Boolean': + if (value === 'true') { + return true; + } else if (value === 'false') { + return false; + } else { + return value; + } + default: + return value; + } +} + +export default async function getQueries( + request: NextRequest, + content_type: string +) { + // Get the schema for the model in Prisma + + interface Field { + name: string; + type: string; + } + + interface Schema { + fields: Field[]; + } + + const schema: Schema | undefined = prisma + ._getDmmf() + .datamodel.models.find( + (model: { name: string }) => model.name === content_type + ); + + if (!schema) { + throw new Error(`Model ${content_type} not found in Prisma schema.`); + } + + const query_entries = request.nextUrl.searchParams.entries(); + const output: { [key: string]: string | number | boolean } = {}; + + for (const [key, val] of query_entries) { + // Fetch the field type from the Prisma schema + const field = schema.fields.find((field) => field.name === key); + if (field) { + output[key] = typeCast(val, field.type); + } else { + output[key] = val; // If field is not found in schema, return as is + } + } + return output; +} diff --git a/app/(api)/_utils/request/isBodyEmpty.ts b/app/(api)/_utils/request/isBodyEmpty.ts new file mode 100644 index 0000000..2519abb --- /dev/null +++ b/app/(api)/_utils/request/isBodyEmpty.ts @@ -0,0 +1,3 @@ +export default function isBodyEmpty(obj: object) { + return Object.keys(obj).length === 0; +} diff --git a/app/(api)/_utils/request/prependAttributes.js b/app/(api)/_utils/request/prependAttributes.js new file mode 100644 index 0000000..d93dbd0 --- /dev/null +++ b/app/(api)/_utils/request/prependAttributes.js @@ -0,0 +1,71 @@ +/** + * Takes in a json object and renames one attribute. + */ +export function renameAttribute(jsonObj, oldFieldName, newFieldName) { + if (jsonObj[oldFieldName]) { + // Create a new object with the renamed attribute + const renamedObject = { + ...jsonObj, + [newFieldName]: jsonObj[oldFieldName], + }; + + // Delete the old attribute + delete renamedObject[oldFieldName]; + return renamedObject; + } + + // Return the original object if the old attribute doesn't exist + return jsonObj; +} + +/** + * subset of renaming attribute, appends a prefix of "prefix" before the attribute name + * "prefix" comes from mongodb nested documents + */ +export function prependToAttribute(jsonObj, attribute, prefix) { + return renameAttribute(jsonObj, attribute, `${prefix}${attribute}`); +} + +/** + * For each attribute within an updater such as: + * { + * "$set": {"name": "austin", "age": 20} + * } + * (in this case, the fields in question are "name" and "age" and the updater is "$set") + * Append the prefix of prefix to each attribute. + * + * This results in: + * { + * "$set": {"PREFIXname": "austin", "PREFIXage": 20}, + * } + * where prefix = PREFIX + */ +export function prependObjectAttributes(jsonObj, prefix) { + for (const key of Object.keys(jsonObj)) { + jsonObj = prependToAttribute(jsonObj, key, prefix); + } + return jsonObj; +} + +/** + * For each updater within a request body + * { + * "$set": {"name": "austin", "age": 20}, + * "$push": {"pokemon": ..., ...} + * } + * (in this case, the updaters in question are "$set" and "$push" + * run prependObjectAttributes() on the content within + * + * This results in: + * { + * "$set": {"PREFIXname": "austin", "PREFIXage": 20}, + * "$push": {"PREFIXpokemon": ..., ...} + * } + * where prefix = PREFIX + */ +export function prependAllAttributes(jsonObj, prefix) { + for (const [key, val] of Object.entries(jsonObj)) { + jsonObj[key] = prependObjectAttributes(val, prefix); + } + return jsonObj; +} diff --git a/app/(api)/_utils/response/DuplicateError.ts b/app/(api)/_utils/response/DuplicateError.ts new file mode 100644 index 0000000..df40095 --- /dev/null +++ b/app/(api)/_utils/response/DuplicateError.ts @@ -0,0 +1,9 @@ +import HttpError from './HttpError'; + +export default class DuplicateError extends HttpError { + constructor(message: string) { + super(message); + this.name = 'DuplicateError'; + this.status = 409; + } +} \ No newline at end of file diff --git a/app/(api)/_utils/response/Errors.ts b/app/(api)/_utils/response/Errors.ts new file mode 100644 index 0000000..e3557b6 --- /dev/null +++ b/app/(api)/_utils/response/Errors.ts @@ -0,0 +1,15 @@ +import HttpError from './HttpError'; +import NoContentError from './NoContentError'; +import NotFoundError from './NotFoundError'; +import NotAuthenticatedError from './NotAuthenticatedError'; +import ForbiddenError from './ForbiddenError'; +import DuplicateError from './DuplicateError'; + +export { + HttpError, + NoContentError, + NotFoundError, + NotAuthenticatedError, + ForbiddenError, + DuplicateError, +}; \ No newline at end of file diff --git a/app/(api)/_utils/response/ForbiddenError.ts b/app/(api)/_utils/response/ForbiddenError.ts new file mode 100644 index 0000000..2f1bf4b --- /dev/null +++ b/app/(api)/_utils/response/ForbiddenError.ts @@ -0,0 +1,9 @@ +import HttpError from './HttpError'; + +export default class ForbiddenError extends HttpError { + constructor(message: string) { + super(message); + this.name = 'ForbiddenError'; + this.status = 403; + } +} \ No newline at end of file diff --git a/app/(api)/_utils/response/HttpError.ts b/app/(api)/_utils/response/HttpError.ts new file mode 100644 index 0000000..a4648d5 --- /dev/null +++ b/app/(api)/_utils/response/HttpError.ts @@ -0,0 +1,8 @@ +export default class HttpError extends Error { + public status: number; + + constructor(message: string = 'Internal Server Error') { + super(message); + this.status = 400; + } +} \ No newline at end of file diff --git a/app/(api)/_utils/response/NoContentError.ts b/app/(api)/_utils/response/NoContentError.ts new file mode 100644 index 0000000..2ec637c --- /dev/null +++ b/app/(api)/_utils/response/NoContentError.ts @@ -0,0 +1,9 @@ +import HttpError from './HttpError'; + +export default class NoContentError extends HttpError { + constructor() { + super('No Content Provided'); + this.name = 'NoContentError'; + this.status = 400; // setting to 204 doesn't work w/ Nextjs + } +} \ No newline at end of file diff --git a/app/(api)/_utils/response/NotAuthenticatedError.ts b/app/(api)/_utils/response/NotAuthenticatedError.ts new file mode 100644 index 0000000..2ec637c --- /dev/null +++ b/app/(api)/_utils/response/NotAuthenticatedError.ts @@ -0,0 +1,9 @@ +import HttpError from './HttpError'; + +export default class NoContentError extends HttpError { + constructor() { + super('No Content Provided'); + this.name = 'NoContentError'; + this.status = 400; // setting to 204 doesn't work w/ Nextjs + } +} \ No newline at end of file diff --git a/app/(api)/_utils/response/NotFoundError.ts b/app/(api)/_utils/response/NotFoundError.ts new file mode 100644 index 0000000..437c1b5 --- /dev/null +++ b/app/(api)/_utils/response/NotFoundError.ts @@ -0,0 +1,9 @@ +import HttpError from './HttpError'; + +export default class NotFoundError extends HttpError { + constructor(message: string) { + super(message); + this.name = 'NotFoundError'; + this.status = 404; + } +} \ No newline at end of file diff --git a/app/(api)/callback/withCallback.ts b/app/(api)/callback/withCallback.ts new file mode 100644 index 0000000..f22197a --- /dev/null +++ b/app/(api)/callback/withCallback.ts @@ -0,0 +1,9 @@ +import callbacks from '../../_utils/callbacks'; + +export default function WithCallback(func: (...args: unknown[]) => unknown) { + return async (...args: unknown[]) => { + const res = await func(...args); + callbacks.onUpdate(); + return res; + }; +} diff --git a/app/(pages)/_utils/convertFileToMediaItem.ts b/app/(pages)/_utils/convertFileToMediaItem.ts new file mode 100644 index 0000000..8ee74dd --- /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..08a3899 --- /dev/null +++ b/app/(pages)/_utils/uploadMediaItem.ts @@ -0,0 +1,75 @@ +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'; + } +} diff --git a/app/_types/auth/AuthToken.ts b/app/_types/auth/AuthToken.ts new file mode 100644 index 0000000..8f32d18 --- /dev/null +++ b/app/_types/auth/AuthToken.ts @@ -0,0 +1,6 @@ +import User from './User'; + +export interface AuthToken extends User { + iat: number; + exp: number; +} diff --git a/app/_types/auth/User.ts b/app/_types/auth/User.ts new file mode 100644 index 0000000..f71a812 --- /dev/null +++ b/app/_types/auth/User.ts @@ -0,0 +1,5 @@ +export default interface User { + _id: string; + email: string; + password: string; +} diff --git a/app/_types/auth/UserCredentials.ts b/app/_types/auth/UserCredentials.ts new file mode 100644 index 0000000..87987e0 --- /dev/null +++ b/app/_types/auth/UserCredentials.ts @@ -0,0 +1,4 @@ +export default interface UserCredentials { + email: string; + password: string; +} diff --git a/app/_types/content/BaseContentItem.ts b/app/_types/content/BaseContentItem.ts new file mode 100644 index 0000000..efa577e --- /dev/null +++ b/app/_types/content/BaseContentItem.ts @@ -0,0 +1,9 @@ +export default interface BaseContentItem { + _id: string; + _name: string; + _description: string | null; + _published: boolean; + _created_at: string; + _last_modified: string; + [key: string]: any; +} diff --git a/app/_types/media/MediaItem.ts b/app/_types/media/MediaItem.ts new file mode 100644 index 0000000..d43e6dc --- /dev/null +++ b/app/_types/media/MediaItem.ts @@ -0,0 +1,16 @@ +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; diff --git a/app/_types/response/response.ts b/app/_types/response/response.ts new file mode 100644 index 0000000..9ba80ac --- /dev/null +++ b/app/_types/response/response.ts @@ -0,0 +1,5 @@ +export default interface CMSResponse { + ok: boolean; + body: any; + error: string | null; +} diff --git a/app/_utils/callbacks.ts b/app/_utils/callbacks.ts new file mode 100644 index 0000000..0b39985 --- /dev/null +++ b/app/_utils/callbacks.ts @@ -0,0 +1,13 @@ +let callbacks; +try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const callbacksModule = require('../../build-assets/callbacks.js'); + callbacks = callbacksModule.default; +} catch (e) { + callbacks = { + onUpdate: () => {}, + }; +} +export default callbacks as { + onUpdate: () => void; +}; diff --git a/app/_utils/schema.ts b/app/_utils/schema.ts new file mode 100644 index 0000000..e220786 --- /dev/null +++ b/app/_utils/schema.ts @@ -0,0 +1,12 @@ +import { ContentSchema } from '@dist/index'; +let schemaJSON; +try { + schemaJSON = require('../../build-assets/schema.json'); +} catch (e) { + schemaJSON = { + schema: {}, + }; +} + +const schema = ContentSchema.fromJSON(schemaJSON); +export default schema; diff --git a/app/_utils/settings.ts b/app/_utils/settings.ts new file mode 100644 index 0000000..f470e0c --- /dev/null +++ b/app/_utils/settings.ts @@ -0,0 +1,11 @@ +let settings; +try { + settings = require('../../build-assets/settings.json'); +} catch (e) { + settings = { + authExpHours: 24, + }; +} +export default settings as { + authExpHours: number; +}; diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..4a27102 --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,6 @@ +// This is the declaration file that provides type information +import { ContentSchema } from './schema/ContentSchema'; +import { ContentType, FieldType, Field } from './schema/ContentType'; + +// Declare what you export from this module for TS consumers +export { ContentSchema, ContentType, FieldType, Field }; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..2e8a15a --- /dev/null +++ b/dist/index.js @@ -0,0 +1,9 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { ContentSchema } = require('./schema/ContentSchema'); +const { ContentType, FieldType } = require('./schema/ContentType'); + +module.exports = { + ContentSchema, + ContentType, + FieldType, +}; diff --git a/dist/schema/ContentSchema.d.ts b/dist/schema/ContentSchema.d.ts new file mode 100644 index 0000000..4ab015a --- /dev/null +++ b/dist/schema/ContentSchema.d.ts @@ -0,0 +1,23 @@ +import { ContentType } from './ContentType'; + +export class ContentSchema { + private schema = {}; + + constructor(schema = {}); + + // Getter to retrieve a ContentType by its key + public get(key: string): ContentType | undefined; + + // Setter to add or update a ContentType + public set(key: string, contentType: ContentType): void; + + // Method to get all content types + public getAll(): { [key: string]: ContentType }; + public getNames(): string[] { + return Object.keys(this.schema); + } + + public toJSON(); + + public static fromJSON(json: object): ContentSchema; +} diff --git a/dist/schema/ContentSchema.js b/dist/schema/ContentSchema.js new file mode 100644 index 0000000..7012fe5 --- /dev/null +++ b/dist/schema/ContentSchema.js @@ -0,0 +1,47 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { ContentType } = require('./ContentType'); + +class ContentSchema { + constructor(schema = {}) { + this.schema = schema; + } + + // Getter to retrieve a ContentType by its key + get(key) { + return this.schema[key]; + } + + // Setter to add or update a ContentType + set(key, contentType) { + this.schema[key] = contentType; + } + + // Method to get all content types + getAll() { + return this.schema; + } + + getNames() { + return Object.keys(this.schema); + } + + toJSON() { + return { + schema: Object.fromEntries( + Object.entries(this.schema).map(([key, value]) => [key, value.toJSON()]) + ), + }; + } + + static fromJSON(json) { + const schema = new ContentSchema(); + Object.entries(json.schema).forEach(([key, value]) => { + const contentType = ContentType.fromJSON(value); + schema.set(key, contentType); + }); + + return schema; + } +} + +module.exports = { ContentSchema }; diff --git a/dist/schema/ContentType.d.ts b/dist/schema/ContentType.d.ts new file mode 100644 index 0000000..8f720d1 --- /dev/null +++ b/dist/schema/ContentType.d.ts @@ -0,0 +1,54 @@ +export interface Field { + name: string; + displayName?: string; + type: FieldType; + required?: boolean; + visible: boolean; + defaultValue?: any; + isPopulated?: (value: any) => boolean; +} + +export const FieldType = { + SHORT_TEXT: 'shortText', + LONG_TEXT: 'longText', + DATE: 'date', + BOOLEAN: 'boolean', + MEDIA_LIST: 'mediaList', +}; + +export class ContentType { + private name: string; + private singularDisplayName: string; + private pluralDisplayName: string; + private fields: { [key: string]: Field } = {}; + + constructor({ + name, + singularDisplayName = name, + pluralDisplayName = singularDisplayName, + }: { + name: string; + singularDisplayName?: string; + pluralDisplayName?: string; + }); + + public createField({ + name, + displayName, + type, + required = false, + visible = true, + defaultValue, + isPopulated, + }: Field); + + public getName(); + public getSingularDisplayName(); + public getPluralDisplayName(); + public getField(fieldName: string); + public getFieldNames(); + public getFieldArray(); + public setFields(fields: { [key: string]: Field }); + public toJSON(); + public static fromJSON(json: object): ContentType; +} diff --git a/dist/schema/ContentType.js b/dist/schema/ContentType.js new file mode 100644 index 0000000..f86e1d9 --- /dev/null +++ b/dist/schema/ContentType.js @@ -0,0 +1,143 @@ +const FieldType = { + SHORT_TEXT: 'shortText', + LONG_TEXT: 'longText', + DATE: 'date', + BOOLEAN: 'boolean', + MEDIA_LIST: 'mediaList', +}; + +const FieldTypeAttributes = { + [FieldType.SHORT_TEXT]: { + defaultValue: '', + isPopulated: Boolean, + }, + [FieldType.LONG_TEXT]: { + defaultValue: '', + isPopulated: Boolean, + }, + [FieldType.DATE]: { + defaultValue: '', + isPopulated: Boolean, + }, + [FieldType.MEDIA_LIST]: { + defaultValue: [], + isPopulated: (value) => value.length !== 0, + }, + [FieldType.BOOLEAN]: { + defaultValue: null, + isPopulated: (value) => value === null, + }, +}; + +const baseFields = { + _name: { + name: '_name', + type: FieldType.SHORT_TEXT, + displayName: 'Internal name', + required: true, + visible: true, + }, + _published: { + name: '_published', + type: FieldType.BOOLEAN, + defaultValue: false, + visible: false, + }, + _created_at: { + name: '_created_at', + type: FieldType.DATE, + visible: false, + }, + _last_modified: { + name: '_last_modified', + type: FieldType.DATE, + visible: false, + }, +}; + +class ContentType { + constructor({ + name, + singularDisplayName = name, + pluralDisplayName = singularDisplayName, + }) { + this.name = name; + this.singularDisplayName = singularDisplayName; + this.pluralDisplayName = pluralDisplayName; + this.fields = JSON.parse(JSON.stringify(baseFields)); + } + + createField({ + name, + displayName, + type, + required = false, + visible = true, + defaultValue, + isPopulated, + }) { + this.fields[name || ''] = { + name: name || '', + displayName: displayName ?? name, + type: type, + required: Boolean(required), + visible: Boolean(visible), + defaultValue: defaultValue ?? FieldTypeAttributes[type].defaultValue, + isPopulated: isPopulated ?? Boolean, + }; + + return this; + } + + getName() { + return this.name; + } + + getSingularDisplayName() { + return this.singularDisplayName; + } + + getPluralDisplayName() { + return this.pluralDisplayName; + } + + getField(fieldName) { + return this.fields[fieldName]; + } + + getFieldNames() { + return Object.keys(this.fields); + } + + getFieldArray() { + return Object.values(this.fields); + } + + setFields(fields) { + this.fields = fields; + } + + toJSON() { + return { + name: this.name, + singularDisplayName: this.singularDisplayName, + pluralDisplayName: this.pluralDisplayName, + fields: this.fields, + }; + } + + static fromJSON(json) { + const contentType = new ContentType({ + name: json.name, + singularDisplayName: json.singularDisplayName, + pluralDisplayName: json.pluralDisplayName, + }); + contentType.setFields(json.fields); + return contentType; + } +} + +module.exports = { + ContentType, + FieldType, +}; diff --git a/package-lock.json b/package-lock.json index 76f2b3f..88648ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@apollo/server": "^4.11.2", "@as-integrations/next": "^3.2.0", "@prisma/client": "^6.2.1", + "cloudinary": "^2.5.1", "graphql": "^16.10.0", "graphql-tag": "^2.12.6", "mongodb": "^6.3.0", @@ -21,7 +22,7 @@ "react-icons": "^4.11.0", "react-quill": "^2.0.0", "readline": "^1.3.0", - "sass": "^1.69.5" + "sass": "^1.85.1" }, "devDependencies": { "@graphql-tools/merge": "^9.0.14", @@ -2469,6 +2470,18 @@ "node": ">=0.8" } }, + "node_modules/cloudinary": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.5.1.tgz", + "integrity": "sha512-CNg6uU53Hl4FEVynkTGpt5bQEAQWDHi3H+Sm62FzKf5uQHipSN2v7qVqS8GRVqeb0T1WNV+22+75DOJeRXYeSQ==", + "dependencies": { + "lodash": "^4.17.21", + "q": "^1.5.1" + }, + "engines": { + "node": ">=9" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -6577,6 +6590,16 @@ "node": ">=6" } }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -7194,9 +7217,10 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sass": { - "version": "1.83.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.83.0.tgz", - "integrity": "sha512-qsSxlayzoOjdvXMVLkzF84DJFc2HZEL/rFyGIKbbilYtAvlCxyuzUeff9LawTn4btVnLKg75Z8MMr1lxU1lfGw==", + "version": "1.85.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.1.tgz", + "integrity": "sha512-Uk8WpxM5v+0cMR0XjX9KfRIacmSG86RH4DCCZjLU2rFh5tyutt9siAXJ7G+YfxQ99Q6wrRMbMlVl6KqUms71ag==", + "license": "MIT", "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", diff --git a/package.json b/package.json index 17d7d1f..8c4d62d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@apollo/server": "^4.11.2", "@as-integrations/next": "^3.2.0", "@prisma/client": "^6.2.1", + "cloudinary": "^2.5.1", "graphql": "^16.10.0", "graphql-tag": "^2.12.6", "mongodb": "^6.3.0", @@ -31,7 +32,7 @@ "react-icons": "^4.11.0", "react-quill": "^2.0.0", "readline": "^1.3.0", - "sass": "^1.69.5" + "sass": "^1.85.1" }, "devDependencies": { "@graphql-tools/merge": "^9.0.14", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8899bc3..27e335c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,6 +19,23 @@ model Inventory { stock_on_order Int } +model Media { + id String @id @default(uuid()) + cloudinary_id String? + name String + type String + format String + src String + alt String? + size Int + width Int? + height Int? + created_at DateTime? @default(now()) + last_modified DateTime? @default(now()) +} + + + model Product { id String @id @default(uuid()) inventory Inventory?