diff --git a/api/functions/graphql/nexus-typegen.ts b/api/functions/graphql/nexus-typegen.ts index 65e0512..45c3262 100644 --- a/api/functions/graphql/nexus-typegen.ts +++ b/api/functions/graphql/nexus-typegen.ts @@ -28,6 +28,11 @@ declare global { } export interface NexusGenInputs { + ImageInput: { // input type + id?: string | null; // String + name?: string | null; // String + url: string; // String! + } MakerRoleInput: { // input type id: number; // Int! level: NexusGenEnums['RoleLevelEnum']; // RoleLevelEnum! @@ -36,7 +41,7 @@ export interface NexusGenInputs { id: number; // Int! } ProfileDetailsInput: { // input type - avatar?: string | null; // String + avatar?: NexusGenInputs['ImageInput'] | null; // ImageInput bio?: string | null; // String discord?: string | null; // String email?: string | null; // String @@ -59,7 +64,7 @@ export interface NexusGenInputs { } StoryInputType: { // input type body: string; // String! - cover_image?: string | null; // String + cover_image?: NexusGenInputs['ImageInput'] | null; // ImageInput id?: number | null; // Int is_published?: boolean | null; // Boolean tags: string[]; // [String!]! @@ -95,7 +100,6 @@ export interface NexusGenScalars { export interface NexusGenObjects { Author: { // root type - avatar: string; // String! id: number; // Int! join_date: NexusGenScalars['Date']; // Date! lightning_address?: string | null; // String @@ -111,7 +115,6 @@ export interface NexusGenObjects { applicants_count: number; // Int! applications: NexusGenRootTypes['BountyApplication'][]; // [BountyApplication!]! body: string; // String! - cover_image?: string | null; // String createdAt: NexusGenScalars['Date']; // Date! deadline: string; // String! excerpt: string; // String! @@ -129,7 +132,6 @@ export interface NexusGenObjects { workplan: string; // String! } Category: { // root type - cover_image?: string | null; // String icon?: string | null; // String id: number; // Int! title: string; // String! @@ -154,7 +156,6 @@ export interface NexusGenObjects { title: string; // String! } Hackathon: { // root type - cover_image: string; // String! description: string; // String! end_date: NexusGenScalars['Date']; // Date! id: number; // Int! @@ -181,7 +182,6 @@ export interface NexusGenObjects { } Mutation: {}; MyProfile: { // root type - avatar: string; // String! bio?: string | null; // String discord?: string | null; // String email?: string | null; // String @@ -213,13 +213,10 @@ export interface NexusGenObjects { votes_count: number; // Int! } Project: { // root type - cover_image: string; // String! description: string; // String! id: number; // Int! lightning_address?: string | null; // String lnurl_callback_url?: string | null; // String - screenshots: string[]; // [String!]! - thumbnail_image: string; // String! title: string; // String! votes_count: number; // Int! website: string; // String! @@ -237,7 +234,6 @@ export interface NexusGenObjects { } Story: { // root type body: string; // String! - cover_image?: string | null; // String createdAt: NexusGenScalars['Date']; // Date! excerpt: string; // String! id: number; // Int! @@ -254,13 +250,11 @@ export interface NexusGenObjects { title: string; // String! } Tournament: { // root type - cover_image: string; // String! description: string; // String! end_date: NexusGenScalars['Date']; // Date! id: number; // Int! location: string; // String! start_date: NexusGenScalars['Date']; // Date! - thumbnail_image: string; // String! title: string; // String! website: string; // String! } @@ -268,7 +262,6 @@ export interface NexusGenObjects { description: string; // String! ends_at: NexusGenScalars['Date']; // Date! id: number; // Int! - image: string; // String! location: string; // String! starts_at: NexusGenScalars['Date']; // Date! title: string; // String! @@ -280,7 +273,6 @@ export interface NexusGenObjects { question: string; // String! } TournamentJudge: { // root type - avatar: string; // String! company: string; // String! name: string; // String! } @@ -296,7 +288,6 @@ export interface NexusGenObjects { } TournamentPrize: { // root type amount: string; // String! - image: string; // String! title: string; // String! } TournamentProjectsResponse: { // root type @@ -305,7 +296,6 @@ export interface NexusGenObjects { projects: NexusGenRootTypes['Project'][]; // [Project!]! } User: { // root type - avatar: string; // String! bio?: string | null; // String discord?: string | null; // String github?: string | null; // String diff --git a/api/functions/graphql/schema.graphql b/api/functions/graphql/schema.graphql index a3ddf7e..4765fbc 100644 --- a/api/functions/graphql/schema.graphql +++ b/api/functions/graphql/schema.graphql @@ -115,6 +115,12 @@ type Hackathon { website: String! } +input ImageInput { + id: String + name: String + url: String! +} + type LnurlDetails { commentAllowed: Int maxSendable: Int @@ -219,7 +225,7 @@ type PostComment { } input ProfileDetailsInput { - avatar: String + avatar: ImageInput bio: String discord: String email: String @@ -331,7 +337,7 @@ type Story implements PostBase { input StoryInputType { body: String! - cover_image: String + cover_image: ImageInput id: Int is_published: Boolean tags: [String!]! diff --git a/api/functions/graphql/types/category.js b/api/functions/graphql/types/category.js index 0e7b205..4f3f5b8 100644 --- a/api/functions/graphql/types/category.js +++ b/api/functions/graphql/types/category.js @@ -4,7 +4,8 @@ const { extendType, nonNull, } = require('nexus'); -const { prisma } = require('../../../prisma') +const { prisma } = require('../../../prisma'); +const { resolveImgObjectToUrl } = require('../../../utils/resolveImageUrl'); const Category = objectType({ @@ -12,7 +13,11 @@ const Category = objectType({ definition(t) { t.nonNull.int('id'); t.nonNull.string('title'); - t.string('cover_image'); + t.string('cover_image', { + async resolve(parent) { + return prisma.category.findUnique({ where: { id: parent.id } }).cover_image_rel().then(resolveImgObjectToUrl) + } + }); t.string('icon'); diff --git a/api/functions/graphql/types/hackathon.js b/api/functions/graphql/types/hackathon.js index c0b139a..6212b92 100644 --- a/api/functions/graphql/types/hackathon.js +++ b/api/functions/graphql/types/hackathon.js @@ -6,6 +6,7 @@ const { nonNull, } = require('nexus'); const { prisma } = require('../../../prisma'); +const { resolveImgObjectToUrl } = require('../../../utils/resolveImageUrl'); @@ -15,7 +16,11 @@ const Hackathon = objectType({ t.nonNull.int('id'); t.nonNull.string('title'); t.nonNull.string('description'); - t.nonNull.string('cover_image'); + t.nonNull.string('cover_image', { + async resolve(parent) { + return prisma.hackathon.findUnique({ where: { id: parent.id } }).cover_image_rel().then(resolveImgObjectToUrl) + } + }); t.nonNull.date('start_date'); t.nonNull.date('end_date'); t.nonNull.string('location'); diff --git a/api/functions/graphql/types/index.js b/api/functions/graphql/types/index.js index 91cd7d2..2c7ad75 100644 --- a/api/functions/graphql/types/index.js +++ b/api/functions/graphql/types/index.js @@ -1,4 +1,5 @@ const scalars = require('./_scalars') +const misc = require('./misc') const category = require('./category') const project = require('./project') const vote = require('./vote') @@ -10,6 +11,7 @@ const donation = require('./donation') const tag = require('./tag') module.exports = { + ...misc, ...tag, ...scalars, ...category, diff --git a/api/functions/graphql/types/misc.js b/api/functions/graphql/types/misc.js new file mode 100644 index 0000000..2fde16f --- /dev/null +++ b/api/functions/graphql/types/misc.js @@ -0,0 +1,19 @@ +const { objectType, extendType, inputObjectType } = require("nexus"); +const { prisma } = require('../../../prisma'); + +const ImageInput = inputObjectType({ + name: 'ImageInput', + definition(t) { + t.string('id'); + t.string('name'); + t.nonNull.string('url'); + } +}); + + +module.exports = { + // Types + ImageInput, + + // Queries +} \ No newline at end of file diff --git a/api/functions/graphql/types/post.js b/api/functions/graphql/types/post.js index 76881c6..49a91ba 100644 --- a/api/functions/graphql/types/post.js +++ b/api/functions/graphql/types/post.js @@ -15,6 +15,9 @@ const { prisma } = require('../../../prisma'); const { getUserByPubKey } = require('../../../auth/utils/helperFuncs'); const { ApolloError } = require('apollo-server-lambda'); const { marked } = require('marked'); +const { resolveImgObjectToUrl } = require('../../../utils/resolveImageUrl'); +const { ImageInput } = require('./misc'); +const { deleteImage } = require('../../../services/imageUpload.service'); const POST_TYPE = enumType({ @@ -37,7 +40,11 @@ const Author = objectType({ definition(t) { t.nonNull.int('id'); t.nonNull.string('name'); - t.nonNull.string('avatar'); + t.nonNull.string('avatar', { + async resolve(parent) { + return prisma.user.findUnique({ where: { id: parent.id } }).avatar_rel().then(resolveImgObjectToUrl) + } + }); t.nonNull.date('join_date'); t.string('lightning_address'); @@ -71,7 +78,11 @@ const Story = objectType({ t.nonNull.string('type', { resolve: () => t.typeName }); - t.string('cover_image'); + t.string('cover_image', { + async resolve(parent) { + return prisma.story.findUnique({ where: { id: parent.id } }).cover_image_rel().then(resolveImgObjectToUrl) + } + }); t.nonNull.list.nonNull.field('comments', { type: "PostComment", resolve: (parent) => [] @@ -111,7 +122,9 @@ const StoryInputType = inputObjectType({ t.int('id'); t.nonNull.string('title'); t.nonNull.string('body'); - t.string('cover_image'); + t.field('cover_image', { + type: ImageInput + }) t.nonNull.list.nonNull.string('tags'); t.boolean('is_published') } @@ -342,6 +355,64 @@ const getPostById = extendType({ } }) +const addCoverImage = async (providerImageId) => { + const newCoverImage = await prisma.hostedImage.findFirst({ + where: { + provider_image_id: providerImageId + } + }) + + if (!newCoverImage) throw new ApolloError("New cover image not found") + + await prisma.hostedImage.update({ + where: { + id: newCoverImage.id + }, + data: { + is_used: true + } + }) + + return newCoverImage +} + +const getHostedImageIdsFromBody = async (body, oldBodyImagesIds = null) => { + let bodyImageIds = [] + + const regex = /(?:!\[(.*?)\]\((.*?)\))/g + let match; + while ((match = regex.exec(body))) { + const [, , value] = match + + // Useful for old external images in case of duplicates. We need to be sure we are targeting an image from the good story. + const where = oldBodyImagesIds ? { + AND: [ + { url: value }, + { id: { in: oldBodyImagesIds } } + ] + } : + { + url: value, + } + + const hostedImage = await prisma.hostedImage.findFirst({ + where + }) + if (hostedImage) { + bodyImageIds.push(hostedImage.id) + await prisma.hostedImage.update({ + where: { + id: hostedImage.id + }, + data: { + is_used: true + } + }) + } + } + return bodyImageIds +} + const createStory = extendType({ type: 'Mutation', definition(t) { @@ -358,20 +429,64 @@ const createStory = extendType({ let was_published = false; + + // TODO: validate post data + + let coverImage = null + let bodyImageIds = [] + + // Edit story if (id) { const oldPost = await prisma.story.findFirst({ where: { id }, select: { user_id: true, - is_published: true + is_published: true, + cover_image_id: true, + body_image_ids: true } }) was_published = oldPost.is_published; - if (user.id !== oldPost.user_id) - throw new ApolloError("Not post author") - } - // TODO: validate post data + if (user.id !== oldPost.user_id) throw new ApolloError("Not post author") + // Body images + bodyImageIds = await getHostedImageIdsFromBody(body, oldPost.body_image_ids) + + // Old cover image is found + if (oldPost.cover_image_id) { + const oldCoverImage = await prisma.hostedImage.findFirst({ + where: { + id: oldPost.cover_image_id + } + }) + + // New cover image + if (cover_image?.id && cover_image.id !== oldCoverImage?.provider_image_id) { + await deleteImage(oldCoverImage.id) + coverImage = await addCoverImage(cover_image.id) + } else { + coverImage = oldCoverImage + } + } else { + // No old image found and new cover image + if (cover_image?.id) { + coverImage = await addCoverImage(cover_image.id) + } + } + + // Remove unused body images + const unusedImagesIds = oldPost.body_image_ids.filter(x => !bodyImageIds.includes(x)); + unusedImagesIds.map(async i => await deleteImage(i)) + + } else { + // Body images + bodyImageIds = await getHostedImageIdsFromBody(body) + + // New story and new cover image + if (cover_image?.id) { + coverImage = await addCoverImage(cover_image.id) + } + } // Preprocess & insert const htmlBody = marked.parse(body); @@ -383,6 +498,16 @@ const createStory = extendType({ .replace(/"/g, '"') ; + + const coverImageRel = coverImage ? { + cover_image_rel: { + connect: + { + id: coverImage ? coverImage.id : null + } + } + } : {} + if (id) { await prisma.story.update({ where: { id }, @@ -398,7 +523,7 @@ const createStory = extendType({ data: { title, body, - cover_image, + cover_image: '', excerpt, is_published: was_published || is_published, tags: { @@ -415,16 +540,17 @@ const createStory = extendType({ } }) }, + body_image_ids: bodyImageIds, + ...coverImageRel } }) } - - return prisma.story.create({ + return await prisma.story.create({ data: { title, body, - cover_image, + cover_image: '', excerpt, is_published, tags: { @@ -445,7 +571,9 @@ const createStory = extendType({ connect: { id: user.id, } - } + }, + body_image_ids: bodyImageIds, + ...coverImageRel } }) } @@ -470,17 +598,39 @@ const deleteStory = extendType({ const oldPost = await prisma.story.findFirst({ where: { id }, select: { - user_id: true + user_id: true, + body_image_ids: true, + cover_image_id: true } }) if (user.id !== oldPost.user_id) throw new ApolloError("Not post author") - return prisma.story.delete({ + const deletedPost = await prisma.story.delete({ where: { id } }) + + const coverImage = await prisma.hostedImage.findMany({ + where: { + OR: [ + { id: oldPost.cover_image_id }, + { + id: { + in: oldPost.body_image_ids + } + } + ] + }, + select: { + id: true, + provider_image_id: true + } + }) + coverImage.map(async i => await deleteImage(i.id)) + + return deletedPost } }) }, diff --git a/api/functions/graphql/types/project.js b/api/functions/graphql/types/project.js index 58eba7b..642be84 100644 --- a/api/functions/graphql/types/project.js +++ b/api/functions/graphql/types/project.js @@ -8,6 +8,7 @@ const { inputObjectType, } = require('nexus') const { prisma } = require('../../../prisma'); +const { resolveImgObjectToUrl } = require('../../../utils/resolveImageUrl'); const { paginationArgs, getLnurlDetails, lightningAddressToLnurl } = require('./helpers'); const { MakerRole } = require('./users'); @@ -19,9 +20,30 @@ const Project = objectType({ t.nonNull.int('id'); t.nonNull.string('title'); t.nonNull.string('description'); - t.nonNull.string('cover_image'); - t.nonNull.string('thumbnail_image'); - t.nonNull.list.nonNull.string('screenshots'); + t.nonNull.string('cover_image', { + async resolve(parent) { + return prisma.project.findUnique({ where: { id: parent.id } }).cover_image_rel().then(resolveImgObjectToUrl) + } + }); + t.nonNull.string('thumbnail_image', { + async resolve(parent) { + return prisma.project.findUnique({ where: { id: parent.id } }).thumbnail_image_rel().then(resolveImgObjectToUrl) + } + }); + t.nonNull.list.nonNull.string('screenshots', { + async resolve(parent) { + if (!parent.screenshots_ids) return null + const imgObject = await prisma.hostedImage.findMany({ + where: { + id: { in: parent.screenshots_ids } + } + }); + + return imgObject.map(img => { + return resolveImgObjectToUrl(img); + }); + } + }); t.nonNull.string('website'); t.string('lightning_address'); t.string('lnurl_callback_url'); diff --git a/api/functions/graphql/types/tournament.js b/api/functions/graphql/types/tournament.js index c3d1edd..7e05b78 100644 --- a/api/functions/graphql/types/tournament.js +++ b/api/functions/graphql/types/tournament.js @@ -9,6 +9,7 @@ const { booleanArg, } = require('nexus'); const { getUserByPubKey } = require('../../../auth/utils/helperFuncs'); +const { resolveImgObjectToUrl } = require('../../../utils/resolveImageUrl'); const { prisma } = require('../../../prisma'); const { paginationArgs, removeNulls } = require('./helpers'); @@ -19,7 +20,11 @@ const TournamentPrize = objectType({ definition(t) { t.nonNull.string('title'); t.nonNull.string('amount'); - t.nonNull.string('image'); + t.nonNull.string('image', { + async resolve(parent) { + return prisma.tournamentPrize.findUnique({ where: { id: parent.id } }).image_rel().then(resolveImgObjectToUrl) + } + }); } }) @@ -28,7 +33,11 @@ const TournamentJudge = objectType({ definition(t) { t.nonNull.string('name'); t.nonNull.string('company'); - t.nonNull.string('avatar'); + t.nonNull.string('avatar', { + async resolve(parent) { + return prisma.tournamentJudge.findUnique({ where: { id: parent.id } }).avatar_rel().then(resolveImgObjectToUrl) + } + }); } }) @@ -74,7 +83,11 @@ const TournamentEvent = objectType({ definition(t) { t.nonNull.int('id'); t.nonNull.string('title'); - t.nonNull.string('image'); + t.nonNull.string('image', { + async resolve(parent) { + return prisma.tournamentEvent.findUnique({ where: { id: parent.id } }).image_rel().then(resolveImgObjectToUrl) + } + }); t.nonNull.string('description'); t.nonNull.date('starts_at'); t.nonNull.date('ends_at'); @@ -91,8 +104,16 @@ const Tournament = objectType({ t.nonNull.int('id'); t.nonNull.string('title'); t.nonNull.string('description'); - t.nonNull.string('thumbnail_image'); - t.nonNull.string('cover_image'); + t.nonNull.string('thumbnail_image', { + async resolve(parent) { + return prisma.tournament.findUnique({ where: { id: parent.id } }).thumbnail_image_rel().then(resolveImgObjectToUrl) + } + }); + t.nonNull.string('cover_image', { + async resolve(parent) { + return prisma.tournament.findUnique({ where: { id: parent.id } }).cover_image_rel().then(resolveImgObjectToUrl) + } + }); t.nonNull.date('start_date'); t.nonNull.date('end_date'); t.nonNull.string('location'); diff --git a/api/functions/graphql/types/tournaments.js b/api/functions/graphql/types/tournaments.js new file mode 100644 index 0000000..e69de29 diff --git a/api/functions/graphql/types/users.js b/api/functions/graphql/types/users.js index 5e26a0e..b67b64c 100644 --- a/api/functions/graphql/types/users.js +++ b/api/functions/graphql/types/users.js @@ -3,7 +3,10 @@ const { prisma } = require('../../../prisma'); const { objectType, extendType, intArg, nonNull, inputObjectType, stringArg, interfaceType, list, enumType } = require("nexus"); const { getUserByPubKey } = require("../../../auth/utils/helperFuncs"); const { removeNulls } = require("./helpers"); +const { ImageInput } = require('./misc'); const { Tournament } = require('./tournament'); +const { resolveImgObjectToUrl } = require('../../../utils/resolveImageUrl'); +const { deleteImage } = require('../../../services/imageUpload.service'); @@ -13,7 +16,11 @@ const BaseUser = interfaceType({ definition(t) { t.nonNull.int('id'); t.nonNull.string('name'); - t.nonNull.string('avatar'); + t.nonNull.string('avatar', { + async resolve(parent) { + return prisma.user.findUnique({ where: { id: parent.id } }).avatar_rel().then(resolveImgObjectToUrl) + } + }); t.nonNull.date('join_date'); t.string('role'); t.string('jobTitle') @@ -289,7 +296,9 @@ const ProfileDetailsInput = inputObjectType({ name: 'ProfileDetailsInput', definition(t) { t.string('name'); - t.string('avatar'); + t.field('avatar', { + type: ImageInput + }) t.string('email') t.string('jobTitle') t.string('lightning_address') @@ -317,14 +326,48 @@ const updateProfileDetails = extendType({ throw new Error("You have to login"); // TODO: validate new data + // ---------------- + // Check if the user uploaded a new image, and if so, + // remove the old one from the hosting service, then replace it with this one + // ---------------- + let avatarId = user.avatar_id; + if (args.data.avatar.id) { + const newAvatarProviderId = args.data.avatar.id; + const newAvatar = await prisma.hostedImage.findFirst({ + where: { + provider_image_id: newAvatarProviderId + } + }) + + if (newAvatar && newAvatar.id !== user.avatar_id) { + avatarId = newAvatar.id; + + await prisma.hostedImage.update({ + where: { + id: newAvatar.id + }, + data: { + is_used: true + } + }); + + await deleteImage(user.avatar_id) + } + } // Preprocess & insert - return prisma.user.update({ where: { id: user.id, }, - data: removeNulls(args.data) + data: removeNulls({ + ...args.data, + avatar_id: avatarId, + + //hack to remove avatar from args.data + // can be removed later with a schema data validator + avatar: '', + }) }) } }) diff --git a/api/functions/login/login.js b/api/functions/login/login.js index 782f195..49c162d 100644 --- a/api/functions/login/login.js +++ b/api/functions/login/login.js @@ -85,11 +85,21 @@ const loginHandler = async (req, res) => { const nostr_prv_key = generatePrivateKey(); const nostr_pub_key = getPublicKey(nostr_prv_key); + const avatar = await prisma.hostedImage.create({ + data: { + filename: 'avatar.svg', + provider: 'external', + is_used: true, + url: `https://avatars.dicebear.com/api/bottts/${key}.svg`, + provider_image_id: '' + } + }) + const createdUser = await prisma.user.create({ data: { pubKey: key, name: key, - avatar: `https://avatars.dicebear.com/api/bottts/${key}.svg`, + avatar_id: avatar.id, nostr_prv_key, nostr_pub_key, }, diff --git a/api/functions/upload-image-url/upload-image-url.js b/api/functions/upload-image-url/upload-image-url.js index b40682a..91f0eb7 100644 --- a/api/functions/upload-image-url/upload-image-url.js +++ b/api/functions/upload-image-url/upload-image-url.js @@ -5,11 +5,10 @@ const extractKeyFromCookie = require('../../utils/extractKeyFromCookie') const { getUserByPubKey } = require('../../auth/utils/helperFuncs') const { getDirectUploadUrl } = require('../../services/imageUpload.service') const { prisma } = require('../../prisma') +const { getUrlFromProvider } = require('../../utils/resolveImageUrl') const postUploadImageUrl = async (req, res) => { - return res.status(404).send("This api is in progress"); - const userPubKey = await extractKeyFromCookie(req.headers.cookie ?? req.headers.Cookie) const user = await getUserByPubKey(userPubKey) @@ -22,11 +21,16 @@ const postUploadImageUrl = async (req, res) => { try { const uploadUrl = await getDirectUploadUrl() - await prisma.hostedImage.create({ - data: { id: uploadUrl.id, filename }, + const hostedImage = await prisma.hostedImage.create({ + data: { + filename, + url: getUrlFromProvider(uploadUrl.provider, uploadUrl.id), + provider_image_id: uploadUrl.id, + provider: uploadUrl.provider + }, }) - return res.status(200).json(uploadUrl) + return res.status(200).json({ id: hostedImage.id, uploadURL: uploadUrl.uploadURL }) } catch (error) { res.status(500).send('Unexpected error happened, please try again') } diff --git a/api/services/imageUpload.service.js b/api/services/imageUpload.service.js index 642c0de..ef63701 100644 --- a/api/services/imageUpload.service.js +++ b/api/services/imageUpload.service.js @@ -1,11 +1,21 @@ const { CONSTS } = require('../utils') const axios = require('axios') const FormData = require('form-data') +const { prisma } = require('../prisma') const BASE_URL = 'https://api.cloudflare.com/client/v4' const operationUrls = { 'image.uploadUrl': `${BASE_URL}/accounts/${CONSTS.CLOUDFLARE_IMAGE_ACCOUNT_ID}/images/v2/direct_upload`, + 'image.delete': `${BASE_URL}/accounts/${CONSTS.CLOUDFLARE_IMAGE_ACCOUNT_ID}/images/v1/`, +} + +function getAxiosConfig() { + return { + headers: { + Authorization: `Bearer ${CONSTS.CLOUDFLARE_IMAGE_API_KEY}`, + }, + } } async function getDirectUploadUrl() { @@ -27,9 +37,62 @@ async function getDirectUploadUrl() { throw new Error(result.data, { cause: result.data.errors }) } - return result.data.result + const data = result.data.result + + return { id: data.id, uploadURL: data.uploadURL, provider: 'cloudflare' } +} + +async function deleteImageFromProvider(providerImageId) { + try { + const url = operationUrls['image.delete'] + providerImageId + const result = await axios.delete(url, getAxiosConfig()) + + if (!result.data.success) { + throw new Error(result.data, { cause: result.data.errors }) + } + } catch (error) { + throw error + } +} + +async function deleteImage(hostedImageId) { + if (!hostedImageId) throw new Error("argument 'hostedImageId' must be provider") + + const hostedImage = await prisma.hostedImage.findFirst({ + where: { + id: hostedImageId, + }, + }) + + if (!hostedImage) throw new Error(`No HostedImage row found for HostedImage.id=${hostedImageId}`) + if (hostedImage.provider_image_id && hostedImage.provider_image_id === '') + throw new Error(`Field 'provider_image_id' for HostedImage.id=${hostedImageId} must not be empty. Current value '${hostedImage.provider_image_id}'`) + + // Set is_used to false in case of deletion fail from the hosting image provider. The scheduled job will try to delete the HostedImage row + await prisma.hostedImage.update({ + where: { + id: hostedImage.id, + }, + data: { + is_used: false, + }, + }) + + if (hostedImage.provider_image_id && hostedImage.provider_image_id !== '') { + deleteImageFromProvider(hostedImage.provider_image_id) + .then(async () => { + await prisma.hostedImage.delete({ + where: { + id: hostedImageId, + }, + }) + }) + .catch((error) => console.error(error)) + } } module.exports = { getDirectUploadUrl, + deleteImage, + deleteImageFromProvider, } diff --git a/api/utils/consts.js b/api/utils/consts.js index 8f2083b..bc62418 100644 --- a/api/utils/consts.js +++ b/api/utils/consts.js @@ -3,6 +3,7 @@ const JWT_SECRET = process.env.JWT_SECRET const LNURL_AUTH_HOST = process.env.LNURL_AUTH_HOST const CLOUDFLARE_IMAGE_ACCOUNT_ID = process.env.CLOUDFLARE_IMAGE_ACCOUNT_ID const CLOUDFLARE_IMAGE_API_KEY = process.env.CLOUDFLARE_IMAGE_API_KEY +const CLOUDFLARE_IMAGE_ACCOUNT_HASH = process.env.CLOUDFLARE_IMAGE_ACCOUNT_HASH const CONSTS = { JWT_SECRET, @@ -10,6 +11,7 @@ const CONSTS = { LNURL_AUTH_HOST, CLOUDFLARE_IMAGE_ACCOUNT_ID, CLOUDFLARE_IMAGE_API_KEY, + CLOUDFLARE_IMAGE_ACCOUNT_HASH } module.exports = CONSTS diff --git a/api/utils/resolveImageUrl.js b/api/utils/resolveImageUrl.js new file mode 100644 index 0000000..40931ed --- /dev/null +++ b/api/utils/resolveImageUrl.js @@ -0,0 +1,45 @@ +const { CLOUDFLARE_IMAGE_ACCOUNT_HASH } = require('./consts') + +const PROVIDERS = [ + { + name: 'cloudflare', + prefixUrl: `https://imagedelivery.net/${CLOUDFLARE_IMAGE_ACCOUNT_HASH}/`, + variants: [ + { + default: true, + name: 'public', + }, + ], + }, +] + +/** + * resolveImgObjectToUrl + * @param {object} imgObject + * @param {string} variant - List to be defined. DEFAULT TO 'public' + * @returns {string} image url + */ +function resolveImgObjectToUrl(imgObject, variant = null) { + if (!imgObject) return null; + + if (imgObject.provider === 'external') { + return imgObject.url + } + + return getUrlFromProvider(imgObject.provider, imgObject.provider_image_id, variant) +} + +function getUrlFromProvider(provider, providerImageId, variant = null) { + const p = PROVIDERS.find((p) => p.name === provider) + + if (p) { + if (p && p.name === 'cloudflare') { + const variantName = variant ?? p.variants.find((v) => v.default).name + return p.prefixUrl + providerImageId + '/' + variantName + } + } + + throw new Error('Hosting images provider not supported') +} + +module.exports = { resolveImgObjectToUrl, getUrlFromProvider } diff --git a/package-lock.json b/package-lock.json index 21405e4..19b851c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,11 @@ "@reduxjs/toolkit": "^1.8.1", "@remirror/pm": "^1.0.16", "@remirror/react": "^1.0.34", + "@rpldy/mock-sender": "^1.0.1", + "@rpldy/upload-button": "^1.0.1", + "@rpldy/upload-drop-zone": "^1.0.1", + "@rpldy/upload-preview": "^1.0.1", + "@rpldy/uploady": "^1.0.1", "@shopify/react-web-worker": "^5.0.1", "@szhsin/react-menu": "^3.0.2", "@testing-library/jest-dom": "^5.16.4", @@ -6938,6 +6943,170 @@ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==" }, + "node_modules/@rpldy/life-events": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/life-events/-/life-events-1.0.1.tgz", + "integrity": "sha512-z9b8Yi1jq4/Um0BQJVsKwwBps8jB+X6UMJXU3dG7Q4rHe7rIiAQw4fCjN5W3L4b9JF9jTs9yX+X7ouWvBLkPVw==", + "dependencies": { + "@rpldy/shared": "^1.0.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + } + }, + "node_modules/@rpldy/mock-sender": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/mock-sender/-/mock-sender-1.0.1.tgz", + "integrity": "sha512-87UT/az8J2AD6NIYX/uVp6GUqM/M4vVpZsXod14GkNkAOOilGow6R1W/3PW7lxx8bjfM51Moraipq1KNwlIODA==", + "dependencies": { + "@rpldy/sender": "^1.0.1", + "@rpldy/shared": "^1.0.1", + "@rpldy/uploader": "^1.0.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + } + }, + "node_modules/@rpldy/sender": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/sender/-/sender-1.0.1.tgz", + "integrity": "sha512-5lGB2uPP22xESYhXdqzKKqZTtS03e0Gi9xx+1mu3XLEpUH7uu55dUMa8CT1cOzLM94JWwaS3fEQG+yB3n9Q7HQ==", + "dependencies": { + "@rpldy/shared": "^1.0.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + } + }, + "node_modules/@rpldy/shared": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/shared/-/shared-1.0.1.tgz", + "integrity": "sha512-22R1ZI+J4vvD6JhHlevxYwn6PxSZ2eXmP1mHxkW/7MHgTWgcKD2xBhl2khfjub64rqnhhiU1KDumhAWEO3GoAw==", + "dependencies": { + "invariant": "^2.2.4", + "just-throttle": "^1.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + } + }, + "node_modules/@rpldy/shared-ui": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/shared-ui/-/shared-ui-1.0.1.tgz", + "integrity": "sha512-pEKKifp4srk8vWyV7TAmpMRz5Dx66YcjOggP40ZZ7TV+CQfjY+b8TY1zx48ptakHqdpbG6Qzz0+OaA+VrOd3mA==", + "dependencies": { + "@rpldy/shared": "^1.0.1", + "@rpldy/uploader": "^1.0.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@rpldy/simple-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/simple-state/-/simple-state-1.0.1.tgz", + "integrity": "sha512-ku/WBl2RFCqMORGEL9/qklMTVN92mqaLbQAY7JtS+IhpG3hfIzwMEXh1eVAs8bpSU2nrsp2SeR1ykyxU8aFWiw==", + "dependencies": { + "@rpldy/shared": "^1.0.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + } + }, + "node_modules/@rpldy/upload-button": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/upload-button/-/upload-button-1.0.1.tgz", + "integrity": "sha512-HuJuWlI9xljD7rQAmL5kEf40sOLZs8KF4VQCaj0y8+ErnEblmvjh/pSuRp+QgXVPT5NYrCq2YXSqOK9UYTBdsA==", + "dependencies": { + "@rpldy/shared-ui": "^1.0.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@rpldy/upload-drop-zone": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/upload-drop-zone/-/upload-drop-zone-1.0.1.tgz", + "integrity": "sha512-B6fhOuIrzCQhHKal+hoGkiubEVxjzFPja9RoYd5dOtaZ7aMwmTX5dtFgVyaL1hjit7H0npHkD6v27b4K6snCXw==", + "dependencies": { + "@rpldy/shared-ui": "^1.0.1", + "html-dir-content": "^0.3.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@rpldy/upload-preview": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/upload-preview/-/upload-preview-1.0.1.tgz", + "integrity": "sha512-Cq4+9fdgV2VRG4MsD9gnQ6AdRC4hMgPM4o88Iq7ifS5cDKNZa3hPVauSN39VpcoOZca1rDrdVslp9eStqmDTBg==", + "dependencies": { + "@rpldy/shared": "^1.0.1", + "@rpldy/shared-ui": "^1.0.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@rpldy/uploader": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/uploader/-/uploader-1.0.1.tgz", + "integrity": "sha512-QuFvKu/xdCtiQU8Szx/pH/3MIJvnTMtDlmN5P62GuHrgUJw9SypZCCv4hCLuq1LuHs7ZENplToPjvj3pscYFdA==", + "dependencies": { + "@rpldy/life-events": "^1.0.1", + "@rpldy/sender": "^1.0.1", + "@rpldy/shared": "^1.0.1", + "@rpldy/simple-state": "^1.0.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + } + }, + "node_modules/@rpldy/uploady": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/uploady/-/uploady-1.0.1.tgz", + "integrity": "sha512-dwyNdYVy/wtioFfMUDsuAuHKKMi2/zMzKaiuOFGgPpx+3a9cVnbORNW/RcAMO6fVYLQgS4VxnqNSdnjS2m6buw==", + "dependencies": { + "@rpldy/life-events": "^1.0.1", + "@rpldy/shared": "^1.0.1", + "@rpldy/shared-ui": "^1.0.1", + "@rpldy/uploader": "^1.0.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-uploady" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/@rushstack/eslint-patch": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.1.3.tgz", @@ -27166,6 +27335,11 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/html-dir-content": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/html-dir-content/-/html-dir-content-0.3.2.tgz", + "integrity": "sha512-a1EJZbvBGmmFwk9VxFhEgaHkyXUXKTkw0jr0FCvXKCqgzO1H0wbFQbbzRA6FhR3twxAyjqVc80bzGHEmKrYsSw==" + }, "node_modules/html-encoding-sniffer": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", @@ -27780,7 +27954,6 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dev": true, "dependencies": { "loose-envify": "^1.0.0" } @@ -30939,6 +31112,11 @@ "node": ">=8" } }, + "node_modules/just-throttle": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/just-throttle/-/just-throttle-1.1.0.tgz", + "integrity": "sha512-iePC/13XYX1Tyn9C6jY+DG3UEejkDvrKsw5xxgGhtGUwYWmoJm4CoKexscBKELOu3FTyCDzjr21ZJ67AXnz+bg==" + }, "node_modules/jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -75931,6 +76109,106 @@ } } }, + "@rpldy/life-events": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/life-events/-/life-events-1.0.1.tgz", + "integrity": "sha512-z9b8Yi1jq4/Um0BQJVsKwwBps8jB+X6UMJXU3dG7Q4rHe7rIiAQw4fCjN5W3L4b9JF9jTs9yX+X7ouWvBLkPVw==", + "requires": { + "@rpldy/shared": "^1.0.1" + } + }, + "@rpldy/mock-sender": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/mock-sender/-/mock-sender-1.0.1.tgz", + "integrity": "sha512-87UT/az8J2AD6NIYX/uVp6GUqM/M4vVpZsXod14GkNkAOOilGow6R1W/3PW7lxx8bjfM51Moraipq1KNwlIODA==", + "requires": { + "@rpldy/sender": "^1.0.1", + "@rpldy/shared": "^1.0.1", + "@rpldy/uploader": "^1.0.1" + } + }, + "@rpldy/sender": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/sender/-/sender-1.0.1.tgz", + "integrity": "sha512-5lGB2uPP22xESYhXdqzKKqZTtS03e0Gi9xx+1mu3XLEpUH7uu55dUMa8CT1cOzLM94JWwaS3fEQG+yB3n9Q7HQ==", + "requires": { + "@rpldy/shared": "^1.0.1" + } + }, + "@rpldy/shared": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/shared/-/shared-1.0.1.tgz", + "integrity": "sha512-22R1ZI+J4vvD6JhHlevxYwn6PxSZ2eXmP1mHxkW/7MHgTWgcKD2xBhl2khfjub64rqnhhiU1KDumhAWEO3GoAw==", + "requires": { + "invariant": "^2.2.4", + "just-throttle": "^1.1.0" + } + }, + "@rpldy/shared-ui": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/shared-ui/-/shared-ui-1.0.1.tgz", + "integrity": "sha512-pEKKifp4srk8vWyV7TAmpMRz5Dx66YcjOggP40ZZ7TV+CQfjY+b8TY1zx48ptakHqdpbG6Qzz0+OaA+VrOd3mA==", + "requires": { + "@rpldy/shared": "^1.0.1", + "@rpldy/uploader": "^1.0.1" + } + }, + "@rpldy/simple-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/simple-state/-/simple-state-1.0.1.tgz", + "integrity": "sha512-ku/WBl2RFCqMORGEL9/qklMTVN92mqaLbQAY7JtS+IhpG3hfIzwMEXh1eVAs8bpSU2nrsp2SeR1ykyxU8aFWiw==", + "requires": { + "@rpldy/shared": "^1.0.1" + } + }, + "@rpldy/upload-button": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/upload-button/-/upload-button-1.0.1.tgz", + "integrity": "sha512-HuJuWlI9xljD7rQAmL5kEf40sOLZs8KF4VQCaj0y8+ErnEblmvjh/pSuRp+QgXVPT5NYrCq2YXSqOK9UYTBdsA==", + "requires": { + "@rpldy/shared-ui": "^1.0.1" + } + }, + "@rpldy/upload-drop-zone": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/upload-drop-zone/-/upload-drop-zone-1.0.1.tgz", + "integrity": "sha512-B6fhOuIrzCQhHKal+hoGkiubEVxjzFPja9RoYd5dOtaZ7aMwmTX5dtFgVyaL1hjit7H0npHkD6v27b4K6snCXw==", + "requires": { + "@rpldy/shared-ui": "^1.0.1", + "html-dir-content": "^0.3.2" + } + }, + "@rpldy/upload-preview": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/upload-preview/-/upload-preview-1.0.1.tgz", + "integrity": "sha512-Cq4+9fdgV2VRG4MsD9gnQ6AdRC4hMgPM4o88Iq7ifS5cDKNZa3hPVauSN39VpcoOZca1rDrdVslp9eStqmDTBg==", + "requires": { + "@rpldy/shared": "^1.0.1", + "@rpldy/shared-ui": "^1.0.1" + } + }, + "@rpldy/uploader": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/uploader/-/uploader-1.0.1.tgz", + "integrity": "sha512-QuFvKu/xdCtiQU8Szx/pH/3MIJvnTMtDlmN5P62GuHrgUJw9SypZCCv4hCLuq1LuHs7ZENplToPjvj3pscYFdA==", + "requires": { + "@rpldy/life-events": "^1.0.1", + "@rpldy/sender": "^1.0.1", + "@rpldy/shared": "^1.0.1", + "@rpldy/simple-state": "^1.0.1" + } + }, + "@rpldy/uploady": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rpldy/uploady/-/uploady-1.0.1.tgz", + "integrity": "sha512-dwyNdYVy/wtioFfMUDsuAuHKKMi2/zMzKaiuOFGgPpx+3a9cVnbORNW/RcAMO6fVYLQgS4VxnqNSdnjS2m6buw==", + "requires": { + "@rpldy/life-events": "^1.0.1", + "@rpldy/shared": "^1.0.1", + "@rpldy/shared-ui": "^1.0.1", + "@rpldy/uploader": "^1.0.1" + } + }, "@rushstack/eslint-patch": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.1.3.tgz", @@ -91898,6 +92176,11 @@ } } }, + "html-dir-content": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/html-dir-content/-/html-dir-content-0.3.2.tgz", + "integrity": "sha512-a1EJZbvBGmmFwk9VxFhEgaHkyXUXKTkw0jr0FCvXKCqgzO1H0wbFQbbzRA6FhR3twxAyjqVc80bzGHEmKrYsSw==" + }, "html-encoding-sniffer": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", @@ -92347,7 +92630,6 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dev": true, "requires": { "loose-envify": "^1.0.0" } @@ -94641,6 +94923,11 @@ "integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==", "dev": true }, + "just-throttle": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/just-throttle/-/just-throttle-1.1.0.tgz", + "integrity": "sha512-iePC/13XYX1Tyn9C6jY+DG3UEejkDvrKsw5xxgGhtGUwYWmoJm4CoKexscBKELOu3FTyCDzjr21ZJ67AXnz+bg==" + }, "jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", diff --git a/package.json b/package.json index a7e5946..a3a3b6b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,11 @@ "@reduxjs/toolkit": "^1.8.1", "@remirror/pm": "^1.0.16", "@remirror/react": "^1.0.34", + "@rpldy/mock-sender": "^1.0.1", + "@rpldy/upload-button": "^1.0.1", + "@rpldy/upload-drop-zone": "^1.0.1", + "@rpldy/upload-preview": "^1.0.1", + "@rpldy/uploady": "^1.0.1", "@shopify/react-web-worker": "^5.0.1", "@szhsin/react-menu": "^3.0.2", "@testing-library/jest-dom": "^5.16.4", diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js index ba0c013..0966a9d 100644 --- a/public/mockServiceWorker.js +++ b/public/mockServiceWorker.js @@ -2,7 +2,7 @@ /* tslint:disable */ /** - * Mock Service Worker (0.39.1). + * Mock Service Worker (0.39.2). * @see https://github.com/mswjs/msw * - Please do NOT modify this file. * - Please do NOT serve this file on production. diff --git a/src/App.tsx b/src/App.tsx index 682e36c..d2b29e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -77,7 +77,6 @@ function App() { }, []); - return
makers.bolt.fun diff --git a/src/Components/Inputs/FilesInput/DropInput.jsx b/src/Components/Inputs/FilesInput/DropInput.jsx deleted file mode 100644 index 6a2b73c..0000000 --- a/src/Components/Inputs/FilesInput/DropInput.jsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useToggle } from "@react-hookz/web"; -import React from "react"; -import { FileDrop } from "react-file-drop"; - -export default function DropInput({ - value: files, - onChange, - emptyContent, - draggingContent, - hasFilesContent, - height, - multiple = false, - allowedType = "*", - classes = { - base: "", - idle: "", - dragging: "", - }, -}) { - const [isDragging, toggleDrag] = useToggle(false); - const fileInputRef = React.useRef(null); - - const onAddFiles = (_files) => { - onChange(_files); - // do something with your files... - }; - - const uploadClick = () => { - fileInputRef.current.click(); - }; - - const status = isDragging ? "dragging" : files ? "has-files" : "empty"; - - return ( -
- onAddFiles(files)} - onTargetClick={uploadClick} - onFrameDragEnter={() => toggleDrag(true)} - onFrameDragLeave={() => toggleDrag(false)} - onFrameDrop={() => toggleDrag(false)} - className={`h-full cursor-pointer`} - targetClassName={`h-full ${classes.base} ${ - status === "empty" && classes.idle - }`} - draggingOverFrameClassName={`${classes.dragging}`} - > - {status === "dragging" && draggingContent} - {status === "empty" && emptyContent} - {status === "has-files" && hasFilesContent} - - onAddFiles(e.target.files)} - ref={fileInputRef} - type="file" - className="hidden" - multiple={multiple} - accept={allowedType} - /> -
- ); -} diff --git a/src/Components/Inputs/FilesInput/FileInput.stories.tsx b/src/Components/Inputs/FilesInput/FileInput.stories.tsx deleted file mode 100644 index 5b02efa..0000000 --- a/src/Components/Inputs/FilesInput/FileInput.stories.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { ComponentStory, ComponentMeta } from '@storybook/react'; -import { BsImages } from 'react-icons/bs'; -import Button from 'src/Components/Button/Button'; - -import FilesInput from './FilesInput'; -import FileDropInput from './FilesDropInput'; - -export default { - title: 'Shared/Inputs/Files Input', - component: FilesInput, - -} as ComponentMeta; - -const Template: ComponentStory = (args) => - - -export const DefaultButton = Template.bind({}); -DefaultButton.args = { -} - -export const CustomizedButton = Template.bind({}); -CustomizedButton.args = { - multiple: true, - uploadBtn: -} - -const DropTemplate: ComponentStory = (args) =>
-export const DropZoneInput = DropTemplate.bind({}); -DropZoneInput.args = { - onChange: console.log, -} \ No newline at end of file diff --git a/src/Components/Inputs/FilesInput/FileThumbnail.tsx b/src/Components/Inputs/FilesInput/FileThumbnail.tsx deleted file mode 100644 index b03fb5b..0000000 --- a/src/Components/Inputs/FilesInput/FileThumbnail.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useMemo } from "react"; -import { MdClose } from "react-icons/md"; -import IconButton from "src/Components/IconButton/IconButton"; - -interface Props { - file: File | string, - onRemove?: () => void -} - -function getFileType(file: File | string) { - if (typeof file === 'string') { - if (/^http[^?]*.(jpg|jpeg|gif|png|tiff|bmp)(\?(.*))?$/gmi.test(file)) - return 'image' - if (/\.(pdf|doc|docx)$/.test(file)) - return 'document'; - - return 'unknown' - } - else { - if (file['type'].split('/')[0] === 'image') - return 'image' - - return 'unknown' - } -} - -type ThumbnailFile = { - name: string; - src: string; - type: ReturnType -} - -function processFile(file: Props['file']): ThumbnailFile { - - const fileType = getFileType(file); - - if (typeof file === 'string') return { name: file, src: file, type: fileType }; - - return { - name: file.name, - src: URL.createObjectURL(file), - type: fileType - }; - -} - - -export default function FileThumbnail({ file: f, onRemove }: Props) { - - const file = useMemo(() => processFile(f), [f]) - - return ( -
- -
- - - -
-
- ) -} diff --git a/src/Components/Inputs/FilesInput/FilesDropInput.tsx b/src/Components/Inputs/FilesInput/FilesDropInput.tsx deleted file mode 100644 index 6d96622..0000000 --- a/src/Components/Inputs/FilesInput/FilesDropInput.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { FaImage } from "react-icons/fa"; -import { UnionToObjectKeys } from "src/utils/types/utils"; -import DropInput from "./DropInput"; - - -type Props = { - height?: number - multiple?: boolean; - value?: File[] | string[] | string; - max?: number; - onBlur?: () => void; - onChange?: (files: (File | string)[] | null) => void - uploadBtn?: JSX.Element - uploadText?: string; - allowedType?: 'images'; - classes?: Partial<{ - base: string, - idle: string, - dragging: string, - hasFiles: string - }> -} - -const fileAccept: UnionToObjectKeys = { - images: ".png, .jpg, .jpeg" -} as const; - -const fileUrlToObject = async (url: string, fileName: string = 'filename') => { - const res = await fetch(url); - const contentType = res.headers.get('content-type') as string; - const blob = await res.blob() - const file = new File([blob], fileName, { contentType } as any) - return file -} - -export default function FilesInput({ - height = 200, - multiple, - value, - max = 3, - onBlur, - onChange, - allowedType = 'images', - classes, - ...props -}: Props) { - - - const baseClasses = classes?.base ?? 'p-32 rounded-8 text-center flex flex-col justify-center items-center' - const idleClasses = classes?.idle ?? 'bg-primary-50 hover:bg-primary-25 border border-dashed border-primary-500 text-gray-800' - const draggingClasses = classes?.dragging ?? 'bg-primary-500 text-white' - - return ( - - ) -} - -const defaultEmptyContent = ( - <> -
- {" "} - Drop your files here -
-

- or {" "} -

- -); - -const defaultDraggingContent =

Drop your files here ⬇⬇⬇

; - -const defaultHasFilesContent = ( -

Files Uploaded Successfully!!

-); \ No newline at end of file diff --git a/src/Components/Inputs/FilesInput/FilesInput.tsx b/src/Components/Inputs/FilesInput/FilesInput.tsx deleted file mode 100644 index 85e6cdb..0000000 --- a/src/Components/Inputs/FilesInput/FilesInput.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { createAction } from "@reduxjs/toolkit"; -import React, { ChangeEvent, useCallback, useRef } from "react" -import { BsUpload } from "react-icons/bs"; -import { FaImage } from "react-icons/fa"; -import Button from "src/Components/Button/Button" -import { openModal } from "src/redux/features/modals.slice"; -import { useAppDispatch } from "src/utils/hooks"; -import { useReduxEffect } from "src/utils/hooks/useReduxEffect"; -import { UnionToObjectKeys } from "src/utils/types/utils"; -import FilesThumbnails from "./FilesThumbnails"; - - -type Props = { - multiple?: boolean; - value?: File[] | string[] | string; - max?: number; - onBlur?: () => void; - onChange?: (files: (File | string)[] | null) => void - uploadBtn?: JSX.Element - uploadText?: string; - allowedType?: 'images'; -} - -const fileAccept: UnionToObjectKeys = { - images: ".png, .jpg, .jpeg" -} as const; - -const fileUrlToObject = async (url: string, fileName: string = 'filename') => { - const res = await fetch(url); - const contentType = res.headers.get('content-type') as string; - const blob = await res.blob() - const file = new File([blob], fileName, { contentType } as any) - return file -} - -const INSERT_IMAGE_ACTION = createAction<{ src: string, alt?: string }>('COVER_IMAGE_INSERTED')({ src: '', alt: "" }) - -const FilesInput = React.forwardRef(({ - multiple, - value, - max = 3, - onBlur, - onChange, - allowedType = 'images', - uploadText = 'Upload files', - ...props -}, ref) => { - - - const dispatch = useAppDispatch(); - - const handleClick = () => { - // ref.current.click(); - dispatch(openModal({ - Modal: "InsertImageModal", - props: { - callbackAction: { - type: INSERT_IMAGE_ACTION.type, - payload: { - src: "", - alt: "" - } - } - } - })) - } - - const onInsertImgUrl = useCallback(({ payload: { src, alt } }: typeof INSERT_IMAGE_ACTION) => { - if (typeof value === 'string') - onChange?.([value, src]); - else - onChange?.([...(value ?? []), src]); - }, [onChange, value]) - - useReduxEffect(onInsertImgUrl, INSERT_IMAGE_ACTION.type) - - const handleChange = (e: ChangeEvent) => { - const files = e.target.files && Array.from(e.target.files).slice(0, max); - if (typeof value === 'string') - onChange?.([value, ...(files ?? [])]); - else - onChange?.([...(value ?? []), ...(files ?? [])]); - } - - const handleRemove = async (idx: number) => { - if (!value) return onChange?.([]); - if (typeof value === 'string') - onChange?.([]); - else { - let files = [...value] - files.splice(idx, 1); - - //change all files urls to file objects - const filesConverted = await Promise.all(files.map(async file => { - if (typeof file === 'string') return await fileUrlToObject(file, "") - else return file; - })) - - onChange?.(filesConverted); - } - } - - const canUploadMore = multiple ? - !value || (value && value.length < max) - : - !value || value.length === 0 - - - const uploadBtn = props.uploadBtn ? - React.cloneElement(props.uploadBtn, { onClick: handleClick }) - : - - - return ( - <> - - { - canUploadMore && - <> - {uploadBtn} - - - } - - ) -}) - - -export default FilesInput; \ No newline at end of file diff --git a/src/Components/Inputs/FilesInput/FilesThumbnails.tsx b/src/Components/Inputs/FilesInput/FilesThumbnails.tsx deleted file mode 100644 index 362528e..0000000 --- a/src/Components/Inputs/FilesInput/FilesThumbnails.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useMemo } from 'react' -import FileThumbnail from './FileThumbnail'; - -interface Props { - files?: (File | string)[] | string; - onRemove?: (idx: number) => void -} - -function processFiles(files: Props['files']) { - - if (!files) return []; - if (typeof files === 'string') return [files]; - return files; -} - -export default function FilesThumbnails({ files, onRemove }: Props) { - const filesConverted = useMemo(() => processFiles(files), [files]) - - return ( -
- { - filesConverted.map((file, idx) => onRemove?.(idx)} />) - } -
- ) -} diff --git a/src/Components/Inputs/FilesInputs/AvatarInput/AvatarInput.stories.tsx b/src/Components/Inputs/FilesInputs/AvatarInput/AvatarInput.stories.tsx new file mode 100644 index 0000000..73f451e --- /dev/null +++ b/src/Components/Inputs/FilesInputs/AvatarInput/AvatarInput.stories.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import AvatarInput from './AvatarInput'; +import { WrapFormController } from 'src/utils/storybook/decorators'; +import { ImageType } from '../SingleImageUploadInput/SingleImageUploadInput'; + +export default { + title: 'Shared/Inputs/Files Inputs/Avatar ', + component: AvatarInput, + decorators: [ + WrapFormController<{ avatar: ImageType | null }>({ + logValues: true, + name: "avatar", + defaultValues: { + avatar: null + } + })] +} as ComponentMeta; + +const Template: ComponentStory = (args, context) => { + + return + +} + + +export const Default = Template.bind({}); +Default.args = { +} \ No newline at end of file diff --git a/src/Components/Inputs/FilesInputs/AvatarInput/AvatarInput.tsx b/src/Components/Inputs/FilesInputs/AvatarInput/AvatarInput.tsx new file mode 100644 index 0000000..e912eca --- /dev/null +++ b/src/Components/Inputs/FilesInputs/AvatarInput/AvatarInput.tsx @@ -0,0 +1,105 @@ +import { motion } from 'framer-motion'; +import React, { ComponentProps, useRef } from 'react' +import { AiOutlineCloudUpload } from 'react-icons/ai'; +import { CgArrowsExchangeV } from 'react-icons/cg'; +import { FiCamera } from 'react-icons/fi'; +import { IoMdClose } from 'react-icons/io'; +import { RotatingLines } from 'react-loader-spinner'; +import { Nullable } from 'remirror'; +import { useIsDraggingOnElement } from 'src/utils/hooks'; +import SingleImageUploadInput from '../SingleImageUploadInput/SingleImageUploadInput' + +type Value = ComponentProps['value'] + +interface Props { + width?: number; + isRemovable?: boolean + value: Value; + onChange: (new_value: Nullable) => void +} + +export default function AvatarInput(props: Props) { + + const dropAreaRef = useRef(null!) + const isDragging = useIsDraggingOnElement({ ref: dropAreaRef }); + + return ( +
+ +
+ {!img && +
+

+
+ Add Image +
+
} + {img && + <> + + {!isUploading && +
+ + {props.isRemovable && } +
+ } + } + {isUploading && +
+ +
+ } + {isDraggingOnWindow && +
+ + + + +
+ } +
} + /> +
+ + ) +} diff --git a/src/Components/Inputs/FilesInputs/CoverImageInput/CoverImageInput.stories.tsx b/src/Components/Inputs/FilesInputs/CoverImageInput/CoverImageInput.stories.tsx new file mode 100644 index 0000000..9bedfe5 --- /dev/null +++ b/src/Components/Inputs/FilesInputs/CoverImageInput/CoverImageInput.stories.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { WrapFormController } from 'src/utils/storybook/decorators'; +import { ImageType } from '../SingleImageUploadInput/SingleImageUploadInput'; +import CoverImageInput from './CoverImageInput'; + +export default { + title: 'Shared/Inputs/Files Inputs/Cover Image ', + component: CoverImageInput, + decorators: [ + WrapFormController<{ thumbnail: ImageType | null }>({ + logValues: true, + name: "thumbnail", + defaultValues: { + thumbnail: null + } + })] +} as ComponentMeta; + +const Template: ComponentStory = (args, context) => { + + return
+ +
+ +} + + +export const Default = Template.bind({}); +Default.args = { +} \ No newline at end of file diff --git a/src/Components/Inputs/FilesInputs/CoverImageInput/CoverImageInput.tsx b/src/Components/Inputs/FilesInputs/CoverImageInput/CoverImageInput.tsx new file mode 100644 index 0000000..975366d --- /dev/null +++ b/src/Components/Inputs/FilesInputs/CoverImageInput/CoverImageInput.tsx @@ -0,0 +1,103 @@ +import React, { ComponentProps, useEffect, useRef, useState } from 'react' +import { FaImage } from 'react-icons/fa'; +import { CgArrowsExchangeV } from 'react-icons/cg'; +import { IoMdClose } from 'react-icons/io'; +import { RotatingLines } from 'react-loader-spinner'; +import { Nullable } from 'remirror'; +import SingleImageUploadInput from '../SingleImageUploadInput/SingleImageUploadInput' +import { motion } from 'framer-motion'; +import { AiOutlineCloudUpload } from 'react-icons/ai'; +import { useIsDraggingOnElement } from 'src/utils/hooks'; + +type Value = ComponentProps['value'] + +interface Props { + value: Value; + rounded?: string; + onChange: (new_value: Nullable) => void +} + +export default function CoverImageInput(props: Props) { + + const dropAreaRef = useRef(null!) + const isDragging = useIsDraggingOnElement({ ref: dropAreaRef }); + + + return ( +
+ +
+ {!img &&
+

+
+ Drop a COVER IMAGE here or
Click to browse +
+
} + {img && <> + + {!isUploading && +
+ + + +
+ } + } + {isUploading && +
+ +
+ } + {isDraggingOnWindow && +
+ + + +
+ Drop here to upload +
+
+ } +
} + /> +
+ + ) +} diff --git a/src/Components/Inputs/FilesInputs/FileUploadInput/FileUploadInput.stories.tsx b/src/Components/Inputs/FilesInputs/FileUploadInput/FileUploadInput.stories.tsx new file mode 100644 index 0000000..2e5b4dd --- /dev/null +++ b/src/Components/Inputs/FilesInputs/FileUploadInput/FileUploadInput.stories.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import FileUploadInput from './FileUploadInput'; + +export default { + title: 'Shared/Inputs/Files Inputs/Basic', + component: FileUploadInput, + +} as ComponentMeta; + +const Template: ComponentStory = (args) => + + +export const DefaultButton = Template.bind({}); +DefaultButton.args = { +} \ No newline at end of file diff --git a/src/Components/Inputs/FilesInputs/FileUploadInput/FileUploadInput.tsx b/src/Components/Inputs/FilesInputs/FileUploadInput/FileUploadInput.tsx new file mode 100644 index 0000000..ebfef3c --- /dev/null +++ b/src/Components/Inputs/FilesInputs/FileUploadInput/FileUploadInput.tsx @@ -0,0 +1,141 @@ +import Uploady, { useUploady, useRequestPreSend, UPLOADER_EVENTS, } from "@rpldy/uploady"; +import { asUploadButton } from "@rpldy/upload-button"; +import Button from "src/Components/Button/Button"; +import { fetchUploadUrl } from "../fetch-upload-img-url"; +import ImagePreviews from "./ImagePreviews"; +import { FaImage } from "react-icons/fa"; +import UploadDropZone from "@rpldy/upload-drop-zone"; +import { forwardRef, useCallback } from "react"; +import styles from './styles.module.scss' +import { MdFileUpload } from "react-icons/md"; +import { AiOutlineCloudUpload } from "react-icons/ai"; +import { motion } from "framer-motion"; + +interface Props { + url: string; +} + +const UploadBtn = asUploadButton((props: any) => { + + useRequestPreSend(async (data) => { + + const filename = data.items?.[0].file.name ?? '' + + const url = await fetchUploadUrl({ filename }); + return { + options: { + destination: { + url + } + } + } + }) + + // const handleClick = async () => { + // // Make a request to get the url + // try { + // var bodyFormData = new FormData(); + // bodyFormData.append('requireSignedURLs', "false"); + // const res = await axios({ + // url: 'https://cors-anywhere.herokuapp.com/https://api.cloudflare.com/client/v4/accounts/783da4f06e5fdb9012c0632959a6f5b3/images/v2/direct_upload', + // method: 'POST', + // data: bodyFormData, + // headers: { + // "Authorization": "Bearer Xx2-CdsTliYkq6Ayz-1GX4CZubdQVxMwOSDbajP0", + // } + // }) + // uploady.upload(res.data.result.uploadUrl, { + // destination: res.data.result.uploadUrl + // }) + // } catch (error) { + // console.log(error); + + // } + + + // // make the request with the files + // // uploady.upload() + // } + + return +}); + + +const DropZone = forwardRef((props, ref) => { + const { onClick, ...buttonProps } = props; + + useRequestPreSend(async (data) => { + + const filename = data.items?.[0].file.name ?? '' + + const url = await fetchUploadUrl({ filename }); + return { + options: { + destination: { + url + } + } + } + }) + + const onZoneClick = useCallback( + (e: any) => { + if (onClick) { + onClick(e); + } + }, + [onClick] + ); + + return +
+ Drop your IMAGES here or +
+ + + Drop it to upload + +
+}) + +const DropZoneButton = asUploadButton(DropZone); + + +export default function FileUploadInput(props: Props) { + return ( + { + const { id, filename, variants } = item?.uploadResponse?.data?.result ?? {} + if (id) { + console.log(id, filename, variants); + } + } + }} + > + + {/* */} + + + ) +} diff --git a/src/Components/Inputs/FilesInputs/FileUploadInput/ImagePreviews.tsx b/src/Components/Inputs/FilesInputs/FileUploadInput/ImagePreviews.tsx new file mode 100644 index 0000000..e28b375 --- /dev/null +++ b/src/Components/Inputs/FilesInputs/FileUploadInput/ImagePreviews.tsx @@ -0,0 +1,92 @@ +import UploadPreview, { PreviewComponentProps, PreviewMethods } from '@rpldy/upload-preview' +import { useAbortItem, useItemAbortListener, useItemCancelListener, useItemErrorListener, useItemProgressListener } from '@rpldy/uploady'; +import React, { useState } from 'react' +import { RotatingLines } from 'react-loader-spinner'; + +export default function ImagePreviews() { + return ( +
+ +
+ ) +} + +function CustomImagePreview({ id, url }: PreviewComponentProps) { + + const [progress, setProgress] = useState(0); + const [itemState, setItemState] = useState(STATES.PROGRESS); + + + useItemProgressListener(item => { + if (item.completed > progress) { + setProgress(() => item.completed); + + if (item.completed === 100) { + setItemState(STATES.DONE) + } else { + setItemState(STATES.PROGRESS) + } + } + }, id); + + + + useItemAbortListener(item => { + setItemState(STATES.CANCELLED); + }, id); + + + useItemCancelListener(item => { + setItemState(STATES.CANCELLED); + }, id); + + useItemErrorListener(item => { + setItemState(STATES.ERROR); + }, id); + + return
+ +
+
+ {itemState === STATES.PROGRESS && +
+ +
} + {itemState === STATES.ERROR && +
+ Failed... +
} + {itemState === STATES.CANCELLED && +
+ Cancelled +
} +
; +}; + +const STATES = { + PROGRESS: "PROGRESS", + DONE: "DONE", + CANCELLED: "CANCELLED", + ERROR: "ERROR" +}; + +const STATE_COLORS = { + [STATES.PROGRESS]: "#f4e4a4", + [STATES.DONE]: "#a5f7b3", + [STATES.CANCELLED]: "#f7cdcd", + [STATES.ERROR]: "#ee4c4c" +}; \ No newline at end of file diff --git a/src/Components/Inputs/FilesInputs/FileUploadInput/styles.module.scss b/src/Components/Inputs/FilesInputs/FileUploadInput/styles.module.scss new file mode 100644 index 0000000..217ca39 --- /dev/null +++ b/src/Components/Inputs/FilesInputs/FileUploadInput/styles.module.scss @@ -0,0 +1,25 @@ +.zone { + background-color: #f2f4f7; + border-color: #e4e7ec; + + .active_content { + display: none; + } + + .idle_content { + display: block; + } + + &.active { + background-color: #b3a0ff; + border-color: #9e88ff; + + .active_content { + display: block; + } + + .idle_content { + display: none; + } + } +} diff --git a/src/Components/Inputs/FilesInputs/ScreenshotsInput/ImagePreviews.tsx b/src/Components/Inputs/FilesInputs/ScreenshotsInput/ImagePreviews.tsx new file mode 100644 index 0000000..74d4d64 --- /dev/null +++ b/src/Components/Inputs/FilesInputs/ScreenshotsInput/ImagePreviews.tsx @@ -0,0 +1,97 @@ +import UploadPreview, { PreviewComponentProps, PreviewMethods } from '@rpldy/upload-preview' +import { useAbortItem, useItemAbortListener, useItemCancelListener, useItemErrorListener, useItemProgressListener } from '@rpldy/uploady'; +import { useState } from 'react' +import ScreenShotsThumbnail from './ScreenshotThumbnail' + +export default function ImagePreviews() { + return ( + + ) +} + +function CustomImagePreview({ id, url }: PreviewComponentProps) { + + const [progress, setProgress] = useState(0); + const [itemState, setItemState] = useState(STATES.PROGRESS); + + const abortItem = useAbortItem(); + + + useItemProgressListener(item => { + if (item.completed > progress) { + setProgress(() => item.completed); + + if (item.completed === 100) { + setItemState(STATES.DONE) + } else { + setItemState(STATES.PROGRESS) + } + } + }, id); + + + + useItemAbortListener(item => { + setItemState(STATES.CANCELLED); + }, id); + + + useItemCancelListener(item => { + setItemState(STATES.CANCELLED); + }, id); + + useItemErrorListener(item => { + setItemState(STATES.ERROR); + }, id); + + if (itemState === STATES.DONE || itemState === STATES.CANCELLED) + return null + + return { + abortItem(id) + }} + /> + + // return
+ // + //
+ //
+ // {itemState === STATES.PROGRESS && + //
+ // + //
} + // {itemState === STATES.ERROR && + //
+ // Failed... + //
} + // {itemState === STATES.CANCELLED && + //
+ // Cancelled + //
} + //
; +}; + +const STATES = { + PROGRESS: "PROGRESS", + DONE: "DONE", + CANCELLED: "CANCELLED", + ERROR: "ERROR" +}; diff --git a/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotThumbnail.tsx b/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotThumbnail.tsx new file mode 100644 index 0000000..0003c43 --- /dev/null +++ b/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotThumbnail.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import { FaTimes } from 'react-icons/fa'; +import { RotatingLines } from 'react-loader-spinner'; + +interface Props { + url?: string, + isLoading?: boolean; + isError?: boolean; + onCancel?: () => void; + +} + +export default function ScreenshotThumbnail({ url, isLoading, isError, onCancel }: Props) { + + const isEmpty = !url; + + return ( +
+ {!isEmpty && } +
+
+ {isLoading && +
+ +
} + {isError && +
+ Failed... +
} + {!isEmpty && + + } +
+ ) +} diff --git a/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotsInput.stories.tsx b/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotsInput.stories.tsx new file mode 100644 index 0000000..1575707 --- /dev/null +++ b/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotsInput.stories.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import ScreenshotsInput, { ScreenshotType } from './ScreenshotsInput'; +import { WrapForm, WrapFormController } from 'src/utils/storybook/decorators'; + +export default { + title: 'Shared/Inputs/Files Inputs/Screenshots', + component: ScreenshotsInput, + decorators: [ + WrapFormController<{ screenshots: Array }>({ + logValues: true, + name: "screenshots", + defaultValues: { + screenshots: [] + } + })] +} as ComponentMeta; + +const Template: ComponentStory = (args, context) => { + + return + +} + + +export const Empty = Template.bind({}); +Empty.args = { +} + +export const WithValues = Template.bind({}); +WithValues.decorators = [ + WrapFormController<{ screenshots: Array }>({ + logValues: true, + name: "screenshots", + defaultValues: { + screenshots: [{ + id: '123', + name: 'tree', + url: "https://picsum.photos/id/1021/800/800.jpg" + }, + { + id: '555', + name: 'whatever', + url: "https://picsum.photos/id/600/800/800.jpg" + },] + } + }) as any +]; +WithValues.args = { +} + +export const Full = Template.bind({}); +Full.decorators = [ + WrapFormController<{ screenshots: Array }>({ + logValues: true, + name: "screenshots", + defaultValues: { + screenshots: [ + { + id: '123', + name: 'tree', + url: "https://picsum.photos/id/1021/800/800.jpg" + }, + { + id: '555', + name: 'whatever', + url: "https://picsum.photos/id/600/800/800.jpg" + }, + { + id: '562', + name: 'Moon', + url: "https://picsum.photos/id/32/800/800.jpg" + }, + { + id: '342', + name: 'Sun', + url: "https://picsum.photos/id/523/800/800.jpg" + }, + ] + } + }) as any +]; +Full.args = { +} \ No newline at end of file diff --git a/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotsInput.tsx b/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotsInput.tsx new file mode 100644 index 0000000..b5bf6c0 --- /dev/null +++ b/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotsInput.tsx @@ -0,0 +1,151 @@ +import Uploady, { useRequestPreSend, UPLOADER_EVENTS } from "@rpldy/uploady"; +import { asUploadButton } from "@rpldy/upload-button"; +// import { fetchUploadUrl } from "./fetch-upload-img-url"; +import ImagePreviews from "./ImagePreviews"; +import UploadDropZone from "@rpldy/upload-drop-zone"; +import { forwardRef, useCallback, useState } from "react"; +import styles from './styles.module.scss' +import { AiOutlineCloudUpload } from "react-icons/ai"; +import { motion } from "framer-motion"; +import { getMockSenderEnhancer } from "@rpldy/mock-sender"; +import ScreenshotThumbnail from "./ScreenshotThumbnail"; +import { FiCamera } from "react-icons/fi"; +import { Control, Path, useController } from "react-hook-form"; + + + +const mockSenderEnhancer = getMockSenderEnhancer({ + delay: 1500, +}); + +const MAX_UPLOAD_COUNT = 4 as const; + +export interface ScreenshotType { + id: string, + name: string, + url: string; +} + +interface Props { + value: ScreenshotType[], + onChange: (new_value: ScreenshotType[]) => void +} + + +export default function ScreenshotsInput(props: Props) { + + const { value: uploadedFiles, onChange } = props; + + + const [uploadingCount, setUploadingCount] = useState(0) + + + const canUploadMore = uploadingCount + uploadedFiles.length < MAX_UPLOAD_COUNT; + const placeholdersCount = (MAX_UPLOAD_COUNT - (uploadingCount + uploadedFiles.length + 1)); + + + return ( + { + setUploadingCount(v => v + batch.items.length) + }, + [UPLOADER_EVENTS.ITEM_FINALIZE]: () => setUploadingCount(v => v - 1), + [UPLOADER_EVENTS.ITEM_FINISH]: (item) => { + + // Just for mocking purposes + const dataUrl = URL.createObjectURL(item.file); + + const { id, filename, variants } = item?.uploadResponse?.data?.result ?? { + id: Math.random().toString(), + filename: item.file.name, + variants: [ + "", + dataUrl + ] + } + if (id) { + onChange([...uploadedFiles, { id, name: filename, url: variants[1] }].slice(-MAX_UPLOAD_COUNT)) + } + } + }} + > + +
+ {canUploadMore && } + {uploadedFiles.map(f => { + onChange(uploadedFiles.filter(file => file.id !== f.id)) + }} />)} + + {(placeholdersCount > 0) && + Array(placeholdersCount).fill(0).map((_, idx) => )} +
+
+ ) +} + +const DropZone = forwardRef((props, ref) => { + const { onClick, ...buttonProps } = props; + + + useRequestPreSend(async (data) => { + + const filename = data.items?.[0].file.name ?? '' + + // const url = await fetchUploadUrl({ filename }); + return { + options: { + destination: { + url: "URL" + } + } + } + }) + + const onZoneClick = useCallback( + (e: any) => { + if (onClick) { + onClick(e); + } + }, + [onClick] + ); + + return +
+

+
+ Browse images or
drop + them here +
+
+ + + Drop to upload
+
+
+}) + +const DropZoneButton = asUploadButton(DropZone); diff --git a/src/Components/Inputs/FilesInputs/ScreenshotsInput/styles.module.scss b/src/Components/Inputs/FilesInputs/ScreenshotsInput/styles.module.scss new file mode 100644 index 0000000..217ca39 --- /dev/null +++ b/src/Components/Inputs/FilesInputs/ScreenshotsInput/styles.module.scss @@ -0,0 +1,25 @@ +.zone { + background-color: #f2f4f7; + border-color: #e4e7ec; + + .active_content { + display: none; + } + + .idle_content { + display: block; + } + + &.active { + background-color: #b3a0ff; + border-color: #9e88ff; + + .active_content { + display: block; + } + + .idle_content { + display: none; + } + } +} diff --git a/src/Components/Inputs/FilesInputs/SingleImageUploadInput/ImagePreviews.tsx b/src/Components/Inputs/FilesInputs/SingleImageUploadInput/ImagePreviews.tsx new file mode 100644 index 0000000..74d4d64 --- /dev/null +++ b/src/Components/Inputs/FilesInputs/SingleImageUploadInput/ImagePreviews.tsx @@ -0,0 +1,97 @@ +import UploadPreview, { PreviewComponentProps, PreviewMethods } from '@rpldy/upload-preview' +import { useAbortItem, useItemAbortListener, useItemCancelListener, useItemErrorListener, useItemProgressListener } from '@rpldy/uploady'; +import { useState } from 'react' +import ScreenShotsThumbnail from './ScreenshotThumbnail' + +export default function ImagePreviews() { + return ( + + ) +} + +function CustomImagePreview({ id, url }: PreviewComponentProps) { + + const [progress, setProgress] = useState(0); + const [itemState, setItemState] = useState(STATES.PROGRESS); + + const abortItem = useAbortItem(); + + + useItemProgressListener(item => { + if (item.completed > progress) { + setProgress(() => item.completed); + + if (item.completed === 100) { + setItemState(STATES.DONE) + } else { + setItemState(STATES.PROGRESS) + } + } + }, id); + + + + useItemAbortListener(item => { + setItemState(STATES.CANCELLED); + }, id); + + + useItemCancelListener(item => { + setItemState(STATES.CANCELLED); + }, id); + + useItemErrorListener(item => { + setItemState(STATES.ERROR); + }, id); + + if (itemState === STATES.DONE || itemState === STATES.CANCELLED) + return null + + return { + abortItem(id) + }} + /> + + // return
+ // + //
+ //
+ // {itemState === STATES.PROGRESS && + //
+ // + //
} + // {itemState === STATES.ERROR && + //
+ // Failed... + //
} + // {itemState === STATES.CANCELLED && + //
+ // Cancelled + //
} + //
; +}; + +const STATES = { + PROGRESS: "PROGRESS", + DONE: "DONE", + CANCELLED: "CANCELLED", + ERROR: "ERROR" +}; diff --git a/src/Components/Inputs/FilesInputs/SingleImageUploadInput/ScreenshotThumbnail.tsx b/src/Components/Inputs/FilesInputs/SingleImageUploadInput/ScreenshotThumbnail.tsx new file mode 100644 index 0000000..0003c43 --- /dev/null +++ b/src/Components/Inputs/FilesInputs/SingleImageUploadInput/ScreenshotThumbnail.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import { FaTimes } from 'react-icons/fa'; +import { RotatingLines } from 'react-loader-spinner'; + +interface Props { + url?: string, + isLoading?: boolean; + isError?: boolean; + onCancel?: () => void; + +} + +export default function ScreenshotThumbnail({ url, isLoading, isError, onCancel }: Props) { + + const isEmpty = !url; + + return ( +
+ {!isEmpty && } +
+
+ {isLoading && +
+ +
} + {isError && +
+ Failed... +
} + {!isEmpty && + + } +
+ ) +} diff --git a/src/Components/Inputs/FilesInputs/SingleImageUploadInput/SingleImageUploadInput.stories.tsx b/src/Components/Inputs/FilesInputs/SingleImageUploadInput/SingleImageUploadInput.stories.tsx new file mode 100644 index 0000000..06e9fe5 --- /dev/null +++ b/src/Components/Inputs/FilesInputs/SingleImageUploadInput/SingleImageUploadInput.stories.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import SingleImageUploadInput, { ImageType } from './SingleImageUploadInput'; +import { WrapFormController } from 'src/utils/storybook/decorators'; +import { RotatingLines } from 'react-loader-spinner'; +import { FiCamera, } from 'react-icons/fi'; +import { FaExchangeAlt, FaImage } from 'react-icons/fa'; + +export default { + title: 'Shared/Inputs/Files Inputs/Single Image Upload ', + component: SingleImageUploadInput, + decorators: [ + WrapFormController<{ avatar: ImageType | null }>({ + logValues: true, + name: "avatar", + defaultValues: { + avatar: null + } + })] +} as ComponentMeta; + +const Template: ComponentStory = (args, context) => { + + return + +} + + diff --git a/src/Components/Inputs/FilesInputs/SingleImageUploadInput/SingleImageUploadInput.tsx b/src/Components/Inputs/FilesInputs/SingleImageUploadInput/SingleImageUploadInput.tsx new file mode 100644 index 0000000..44b7ce0 --- /dev/null +++ b/src/Components/Inputs/FilesInputs/SingleImageUploadInput/SingleImageUploadInput.tsx @@ -0,0 +1,141 @@ +import Uploady, { useRequestPreSend, UPLOADER_EVENTS, useAbortAll } from "@rpldy/uploady"; +import { asUploadButton } from "@rpldy/upload-button"; +// import { fetchUploadUrl } from "./fetch-upload-img-url"; +import UploadDropZone from "@rpldy/upload-drop-zone"; +import { forwardRef, ReactElement, useCallback, useState } from "react"; +import styles from './styles.module.scss' +import { getMockSenderEnhancer } from "@rpldy/mock-sender"; +import { NotificationsService } from "src/services"; +import { useIsDraggingOnElement } from 'src/utils/hooks'; +import { fetchUploadImageUrl } from "src/api/uploading"; + + + +const mockSenderEnhancer = getMockSenderEnhancer({ + delay: 1500, + +}); + + +export interface ImageType { + id?: string | null, + name?: string | null, + url: string; +} + +type RenderPropArgs = { + isUploading?: boolean; + img: ImageType | null, + onAbort: () => void, + isDraggingOnWindow?: boolean +} + +interface Props { + value: ImageType | null | undefined, + onChange: (new_value: ImageType | null) => void; + wrapperClass?: string; + render: (args: RenderPropArgs) => ReactElement; +} + + +export default function SingleImageUploadInput(props: Props) { + + const { value, onChange, render } = props; + + + const [currentlyUploadingItem, setCurrentlyUploadingItem] = useState(null) + + + return ( + { + onChange(null) + + setCurrentlyUploadingItem({ + id: item.id, + url: URL.createObjectURL(item.file), + name: item.file.name, + }) + }, + [UPLOADER_EVENTS.ITEM_ERROR]: (item) => { + NotificationsService.error("An error happened while uploading. Please try again.") + }, + [UPLOADER_EVENTS.ITEM_FINALIZE]: () => setCurrentlyUploadingItem(null), + [UPLOADER_EVENTS.ITEM_FINISH]: (item) => { + + const { id, filename, variants } = item?.uploadResponse?.data?.result; + const url = (variants as string[]).find(v => v.includes('public')); + + if (id && url) { + onChange({ id, name: filename, url, }) + } + } + }} + > + + + ) +} + + +const DropZone = forwardRef((props, ref) => { + const { onClick, children, renderProps, ...buttonProps } = props; + + const isDraggingOnWindow = useIsDraggingOnElement() + + useRequestPreSend(async (data) => { + + const filename = data.items?.[0].file.name ?? '' + + const res = await fetchUploadImageUrl({ filename }); + + return { + options: { + destination: { + url: res.uploadURL + }, + } + } + }) + + const onZoneClick = useCallback( + (e: any) => { + if (onClick) { + onClick(e); + } + }, + [onClick] + ); + + return + + {renderProps.render({ + img: renderProps.img, + isUploading: renderProps.isUploading, + isDraggingOnWindow, + })} + +}) + +const DropZoneButton = asUploadButton(DropZone); diff --git a/src/Components/Inputs/FilesInputs/SingleImageUploadInput/styles.module.scss b/src/Components/Inputs/FilesInputs/SingleImageUploadInput/styles.module.scss new file mode 100644 index 0000000..217ca39 --- /dev/null +++ b/src/Components/Inputs/FilesInputs/SingleImageUploadInput/styles.module.scss @@ -0,0 +1,25 @@ +.zone { + background-color: #f2f4f7; + border-color: #e4e7ec; + + .active_content { + display: none; + } + + .idle_content { + display: block; + } + + &.active { + background-color: #b3a0ff; + border-color: #9e88ff; + + .active_content { + display: block; + } + + .idle_content { + display: none; + } + } +} diff --git a/src/Components/Inputs/FilesInputs/ThumbnailInput/ThumbnailInput.stories.tsx b/src/Components/Inputs/FilesInputs/ThumbnailInput/ThumbnailInput.stories.tsx new file mode 100644 index 0000000..a7ad6b3 --- /dev/null +++ b/src/Components/Inputs/FilesInputs/ThumbnailInput/ThumbnailInput.stories.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import ThumbnailInput from './ThumbnailInput'; +import { WrapFormController } from 'src/utils/storybook/decorators'; +import { ImageType } from '../SingleImageUploadInput/SingleImageUploadInput'; + +export default { + title: 'Shared/Inputs/Files Inputs/Thumbnail ', + component: ThumbnailInput, + decorators: [ + WrapFormController<{ thumbnail: ImageType | null }>({ + logValues: true, + name: "thumbnail", + defaultValues: { + thumbnail: null + } + })] +} as ComponentMeta; + +const Template: ComponentStory = (args, context) => { + + return + +} + + +export const Default = Template.bind({}); +Default.args = { +} \ No newline at end of file diff --git a/src/Components/Inputs/FilesInputs/ThumbnailInput/ThumbnailInput.tsx b/src/Components/Inputs/FilesInputs/ThumbnailInput/ThumbnailInput.tsx new file mode 100644 index 0000000..1b677fa --- /dev/null +++ b/src/Components/Inputs/FilesInputs/ThumbnailInput/ThumbnailInput.tsx @@ -0,0 +1,54 @@ +import React, { ComponentProps } from 'react' +import { FiCamera } from 'react-icons/fi'; +import { RotatingLines } from 'react-loader-spinner'; +import { Nullable } from 'remirror'; +import SingleImageUploadInput from '../SingleImageUploadInput/SingleImageUploadInput' + +type Value = ComponentProps['value'] + +interface Props { + width?: number + value: Value; + onChange: (new_value: Nullable) => void +} + +export default function ThumbnailInput(props: Props) { + return ( +
+
+ {img && } + {!img && + <> +

+
+ Add Image +
+ } + {isUploading && +
+ +
+ } +
} + /> +
+ + ) +} diff --git a/src/Components/Inputs/FilesInputs/fetch-upload-img-url.tsx b/src/Components/Inputs/FilesInputs/fetch-upload-img-url.tsx new file mode 100644 index 0000000..e2c9b19 --- /dev/null +++ b/src/Components/Inputs/FilesInputs/fetch-upload-img-url.tsx @@ -0,0 +1,25 @@ +import axios from "axios"; +import { NotificationsService } from "src/services"; + +export async function fetchUploadUrl(options?: Partial<{ filename: string }>) { + + const { filename } = options ?? {} + + try { + const bodyFormData = new FormData(); + bodyFormData.append('requireSignedURLs', "false"); + const res = await axios({ + url: 'https://cors-anywhere.herokuapp.com/https://api.cloudflare.com/client/v4/accounts/783da4f06e5fdb9012c0632959a6f5b3/images/v2/direct_upload', + method: 'POST', + data: bodyFormData, + headers: { + "Authorization": "Bearer XXX", + } + }) + return res.data.result.uploadURL as string; + } catch (error) { + console.log(error); + NotificationsService.error("A network error happened.") + return "couldnt fetch upload url"; + } +} \ No newline at end of file diff --git a/src/Components/Inputs/TagsInput/TagsInput.tsx b/src/Components/Inputs/TagsInput/TagsInput.tsx index f36cd0b..26bbfa6 100644 --- a/src/Components/Inputs/TagsInput/TagsInput.tsx +++ b/src/Components/Inputs/TagsInput/TagsInput.tsx @@ -13,7 +13,9 @@ interface Option { readonly description: string | null } + type Tag = Omit +type Value = { title: Tag['title'] } interface Props { classes?: { @@ -22,11 +24,80 @@ interface Props { } placeholder?: string max?: number; + value: Value[]; + onChange?: (new_value: Value[]) => void; + onBlur?: () => void; [k: string]: any } + +export default function TagsInput({ + classes, + placeholder = 'Write some tags', + max = 5, + value, + onChange, + onBlur, + ...props }: Props) { + + const officalTags = useOfficialTagsQuery(); + + + const handleChange = (newValue: OnChangeValue,) => { + onChange?.([...newValue.map(transformer.optionToTag)]); + onBlur?.(); + } + + + + const maxReached = value.length >= max; + + const currentPlaceholder = maxReached ? '' : value.length > 0 ? "Add Another..." : placeholder; + + const tagsOptions = !maxReached ? (officalTags.data?.officialTags ?? []).filter(t => !value.some((v) => v.title === t.title)).map(transformer.tagToOption) : []; + + return ( +
+ maxReached} - placeholder={currentPlaceholder} - noOptionsMessage={() => { - return maxReached - ? "You've reached the max number of tags." - : "No tags available"; - }} - closeMenuOnSelect={false} - value={value.map(transformer.tagToOption)} - onChange={handleChange as any} - onBlur={onBlur} - components={{ - Option: OptionComponent, - // ValueContainer: CustomValueContainer - }} - styles={colourStyles as any} - theme={(theme) => ({ - ...theme, - borderRadius: 8, - colors: { - ...theme.colors, - primary: 'var(--primary)', - }, - })} - /> - {/*
- {(value as Tag[]).map((tag, idx) => handleRemove(idx)} >{tag.title})} -
*/} -
- ) -} diff --git a/src/Components/Inputs/TextEditor/InsertImageModal/InsertImageModal.tsx b/src/Components/Inputs/TextEditor/InsertImageModal/InsertImageModal.tsx deleted file mode 100644 index 0852dea..0000000 --- a/src/Components/Inputs/TextEditor/InsertImageModal/InsertImageModal.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { FormEvent, useState } from 'react' -import { ModalCard, modalCardVariants } from 'src/Components/Modals/ModalsContainer/ModalsContainer' -import { motion } from 'framer-motion' -import { IoClose } from 'react-icons/io5' -import Button from 'src/Components/Button/Button' -import { useAppDispatch } from 'src/utils/hooks' -import { PayloadAction } from '@reduxjs/toolkit' - -interface Props extends ModalCard { - callbackAction: PayloadAction<{ src: string, alt?: string }> -} - -export default function InsertImageModal({ onClose, direction, callbackAction, ...props }: Props) { - - const [urlInput, setUrlInput] = useState("") - const [altInput, setAltInput] = useState("") - const dispatch = useAppDispatch(); - - const handleSubmit = (e: FormEvent) => { - e.preventDefault() - if (urlInput.length > 10) { - // onInsert({ src: urlInput, alt: altInput }) - const action = Object.assign({}, callbackAction); - action.payload = { src: urlInput, alt: altInput } - dispatch(action) - onClose?.(); - } - } - - return ( - - -

Add Image

-
-
-
-

- Image URL -

-
- setUrlInput(e.target.value)} - placeholder='https://images.com/my-image' - /> -
-
-
-

- Alt Text -

-
- setAltInput(e.target.value)} - placeholder='' - /> -
-
-
-
- {urlInput && {altInput}} -
-
- - -
-
- -
- ) -} diff --git a/src/Components/Inputs/TextEditor/InsertImageModal/InsertImageModal.stories.tsx b/src/Components/Modals/InsertImageModal/InsertImageModal.stories.tsx similarity index 68% rename from src/Components/Inputs/TextEditor/InsertImageModal/InsertImageModal.stories.tsx rename to src/Components/Modals/InsertImageModal/InsertImageModal.stories.tsx index ed4f283..b5d7197 100644 --- a/src/Components/Inputs/TextEditor/InsertImageModal/InsertImageModal.stories.tsx +++ b/src/Components/Modals/InsertImageModal/InsertImageModal.stories.tsx @@ -5,7 +5,7 @@ import InsertImageModal from './InsertImageModal'; import { ModalsDecorator } from 'src/utils/storybook/decorators'; export default { - title: 'Shared/Inputs/Text Editor/Insert Image Modal', + title: 'Shared/Inputs/Files Inputs/Image Modal', component: InsertImageModal, decorators: [ModalsDecorator] @@ -14,4 +14,13 @@ export default { const Template: ComponentStory = (args) => ; export const Default = Template.bind({}); +Default.args = { + callbackAction: { + type: "INSERT_IMAGE_IN_STORY", + payload: { + src: "", + alt: "", + } + } +} diff --git a/src/Components/Modals/InsertImageModal/InsertImageModal.tsx b/src/Components/Modals/InsertImageModal/InsertImageModal.tsx new file mode 100644 index 0000000..4a1205d --- /dev/null +++ b/src/Components/Modals/InsertImageModal/InsertImageModal.tsx @@ -0,0 +1,176 @@ +import React, { FormEvent, useRef, useState } from 'react' +import { ModalCard, modalCardVariants } from 'src/Components/Modals/ModalsContainer/ModalsContainer' +import { motion } from 'framer-motion' +import { IoClose } from 'react-icons/io5' +import Button from 'src/Components/Button/Button' +import { useAppDispatch, useIsDraggingOnElement } from 'src/utils/hooks' +import { PayloadAction } from '@reduxjs/toolkit' +import { RotatingLines } from 'react-loader-spinner' +import { FaExchangeAlt, FaImage } from 'react-icons/fa' +import SingleImageUploadInput, { ImageType } from 'src/Components/Inputs/FilesInputs/SingleImageUploadInput/SingleImageUploadInput' +import { AiOutlineCloudUpload } from 'react-icons/ai' + +interface Props extends ModalCard { + callbackAction: PayloadAction<{ src: string, alt?: string }> +} + +export default function InsertImageModal({ onClose, direction, callbackAction, ...props }: Props) { + + const [uploadedImage, setUploadedImage] = useState(null) + const [altInput, setAltInput] = useState("") + const dispatch = useAppDispatch(); + + const dropAreaRef = useRef(null!) + const isDragging = useIsDraggingOnElement({ ref: dropAreaRef }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + console.log(uploadedImage?.url); + + if (uploadedImage?.url) { + // onInsert({ src: urlInput, alt: altInput }) + const action = Object.assign({}, callbackAction); + action.payload = { src: uploadedImage.url, alt: altInput } + dispatch(action) + onClose?.(); + } + } + + return ( + + +

Add Image

+
+ {/*
+
+

+ Image URL +

+
+ setUrlInput(e.target.value)} + placeholder='https://images.com/my-image' + /> +
+
+
+

+ Alt Text +

+
+ setAltInput(e.target.value)} + placeholder='' + /> +
+
+
+
+ {urlInput && {altInput}} +
*/} +
+ {img && <> + + {!isUploading && + } + } + {!img && + <> +

+
+ Drop an IMAGE here or
Click to browse +
+ } + {isUploading && +
+ +
+ } + {isDraggingOnWindow && +
+ + + +
+ Drop here to upload +
+
+ } +
} + /> +
+

+ Alternative Text +

+
+ setAltInput(e.target.value)} + placeholder='A description for the content of this image' + /> +
+
+
+ + +
+ + +
+ ) +} diff --git a/src/Components/Inputs/TextEditor/InsertImageModal/index.tsx b/src/Components/Modals/InsertImageModal/index.tsx similarity index 100% rename from src/Components/Inputs/TextEditor/InsertImageModal/index.tsx rename to src/Components/Modals/InsertImageModal/index.tsx diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..e154815 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,21 @@ + +import { CONSTS } from "src/utils"; + +export async function fetchLnurlAuth() { + const res = await fetch(CONSTS.apiEndpoint + '/get-login-url', { + credentials: 'include' + }) + const data = await res.json() + return data; +} + +export async function fetchIsLoggedIn(session_token: string) { + const res = await fetch(CONSTS.apiEndpoint + '/is-logged-in', { + credentials: 'include', + headers: { + session_token + } + }); + const data = await res.json(); + return data.logged_in; +} \ No newline at end of file diff --git a/src/api/uploading.ts b/src/api/uploading.ts new file mode 100644 index 0000000..59e77d7 --- /dev/null +++ b/src/api/uploading.ts @@ -0,0 +1,12 @@ + +import axios from "axios"; +import { CONSTS } from "src/utils"; + +export async function fetchUploadImageUrl({ filename }: { filename: string }) { + const res = await axios.post(CONSTS.apiEndpoint + '/upload-image-url', { + filename + }, { + withCredentials: true + }) + return res.data; +} \ No newline at end of file diff --git a/src/features/Auth/pages/LoginPage/LoginPage.tsx b/src/features/Auth/pages/LoginPage/LoginPage.tsx index afdb7cb..5b4b37e 100644 --- a/src/features/Auth/pages/LoginPage/LoginPage.tsx +++ b/src/features/Auth/pages/LoginPage/LoginPage.tsx @@ -10,17 +10,12 @@ import Button from "src/Components/Button/Button"; import { FiCopy } from "react-icons/fi"; import useCopyToClipboard from "src/utils/hooks/useCopyToClipboard"; import { getPropertyFromUnknown, trimText, } from "src/utils/helperFunctions"; +import { fetchIsLoggedIn, fetchLnurlAuth } from "src/api/auth"; import { useErrorHandler } from 'react-error-boundary'; -const fetchLnurlAuth = async () => { - const res = await fetch(CONSTS.apiEndpoint + '/get-login-url', { - credentials: 'include' - }) - const data = await res.json() - return data; -} + export const useLnurlQuery = () => { const [loading, setLoading] = useState(true) @@ -102,15 +97,9 @@ export default function LoginPage() { if (canFetchIsLogged.current === false) return; canFetchIsLogged.current = false; - fetch(CONSTS.apiEndpoint + '/is-logged-in', { - credentials: 'include', - headers: { - session_token - } - }) - .then(data => data.json()) - .then(data => { - if (data.logged_in) { + fetchIsLoggedIn(session_token) + .then(is_logged_in => { + if (is_logged_in) { clearInterval(interval) refetch(); } diff --git a/src/features/Posts/pages/CreatePostPage/Components/BountyForm/BountyForm.tsx b/src/features/Posts/pages/CreatePostPage/Components/BountyForm/BountyForm.tsx index 017bd37..368a9b6 100644 --- a/src/features/Posts/pages/CreatePostPage/Components/BountyForm/BountyForm.tsx +++ b/src/features/Posts/pages/CreatePostPage/Components/BountyForm/BountyForm.tsx @@ -2,8 +2,9 @@ import { yupResolver } from "@hookform/resolvers/yup"; import { Controller, FormProvider, NestedValue, Resolver, SubmitHandler, useForm } from "react-hook-form"; import Button from "src/Components/Button/Button"; import DatePicker from "src/Components/Inputs/DatePicker/DatePicker"; -import FilesInput from "src/Components/Inputs/FilesInput/FilesInput"; import TagsInput from "src/Components/Inputs/TagsInput/TagsInput"; +import { Tag } from "src/graphql"; +import { imageSchema } from "src/utils/validation"; import * as yup from "yup"; import ContentEditor from "../ContentEditor/ContentEditor"; @@ -31,29 +32,14 @@ const schema = yup.object({ .string() .required() .min(50, 'you have to write at least 10 words'), - cover_image: yup - .lazy((value: string | File[]) => { - switch (typeof value) { - case 'object': - return yup - .array() - .test("fileSize", "File Size is too large", (files) => (files as File[]).every(file => file.size <= 5242880)) - .test("fileType", "Unsupported File Format, only png/jpg/jpeg images are allowed", - (files) => (files as File[]).every((file: File) => - ["image/jpeg", "image/png", "image/jpg"].includes(file.type))) - case 'string': - return yup.string().url(); - default: - return yup.mixed() - } - }) + cover_image: imageSchema, }).required(); interface IFormInputs { title: string deadline: Date bounty_amount: number - tags: NestedValue + tags: NestedValue cover_image: NestedValue | string body: string } @@ -86,7 +72,7 @@ export default function BountyForm() {
- ( @@ -97,7 +83,7 @@ export default function BountyForm() { uploadText='Add a cover image' /> )} - /> + /> */}

{errors.cover_image?.message}

@@ -155,10 +141,20 @@ export default function BountyForm() {

Tags

- ( + + )} /> + {errors.tags &&

{errors.tags.message}

} diff --git a/src/features/Posts/pages/CreatePostPage/Components/DraftsContainer/DraftContainer.stories.tsx b/src/features/Posts/pages/CreatePostPage/Components/DraftsContainer/DraftContainer.stories.tsx index 05d2fc1..f47f3a3 100644 --- a/src/features/Posts/pages/CreatePostPage/Components/DraftsContainer/DraftContainer.stories.tsx +++ b/src/features/Posts/pages/CreatePostPage/Components/DraftsContainer/DraftContainer.stories.tsx @@ -14,7 +14,6 @@ export default { decorators: [WithModals, WrapForm({ defaultValues: { tags: [], - cover_image: [], } })] } as ComponentMeta; diff --git a/src/features/Posts/pages/CreatePostPage/Components/DraftsContainer/DraftsContainer.tsx b/src/features/Posts/pages/CreatePostPage/Components/DraftsContainer/DraftsContainer.tsx index 0ef4c01..6ba29a8 100644 --- a/src/features/Posts/pages/CreatePostPage/Components/DraftsContainer/DraftsContainer.tsx +++ b/src/features/Posts/pages/CreatePostPage/Components/DraftsContainer/DraftsContainer.tsx @@ -10,7 +10,7 @@ import { NotificationsService } from 'src/services'; import { getDateDifference } from 'src/utils/helperFunctions'; import { useAppDispatch } from 'src/utils/hooks'; import { useReduxEffect } from 'src/utils/hooks/useReduxEffect'; -import { IStoryFormInputs } from '../../CreateStoryPage/CreateStoryPage'; +import { CreateStoryType, IStoryFormInputs } from '../../CreateStoryPage/CreateStoryPage'; interface Props { id?: string; @@ -28,7 +28,7 @@ export default function DraftsContainer({ id, type, onDraftLoad }: Props) { const [deleteStory] = useDeleteStoryMutation({ refetchQueries: ['GetMyDrafts'] }) - const { setValue } = useFormContext() + const { setValue } = useFormContext() const dispatch = useAppDispatch(); const [loading, setLoading] = useState(false) @@ -45,7 +45,7 @@ export default function DraftsContainer({ id, type, onDraftLoad }: Props) { setValue('title', data.getPostById.title); setValue('tags', data.getPostById.tags); setValue('body', data.getPostById.body); - setValue('cover_image', data.getPostById.cover_image ? [data.getPostById.cover_image] : []); + setValue('cover_image', data.getPostById.cover_image ? { url: data.getPostById.cover_image, id: null, name: null } : null); setValue('is_published', data.getPostById.is_published); } diff --git a/src/features/Posts/pages/CreatePostPage/Components/QuestionForm/QuestionForm.tsx b/src/features/Posts/pages/CreatePostPage/Components/QuestionForm/QuestionForm.tsx index 7a5d86f..9643781 100644 --- a/src/features/Posts/pages/CreatePostPage/Components/QuestionForm/QuestionForm.tsx +++ b/src/features/Posts/pages/CreatePostPage/Components/QuestionForm/QuestionForm.tsx @@ -1,8 +1,8 @@ import { yupResolver } from "@hookform/resolvers/yup"; import { Controller, FormProvider, NestedValue, Resolver, SubmitHandler, useForm } from "react-hook-form"; import Button from "src/Components/Button/Button"; -import FilesInput from "src/Components/Inputs/FilesInput/FilesInput"; import TagsInput from "src/Components/Inputs/TagsInput/TagsInput"; +import { Tag } from "src/graphql"; import * as yup from "yup"; import ContentEditor from "../ContentEditor/ContentEditor"; @@ -29,7 +29,7 @@ const schema = yup.object({ interface IFormInputs { title: string - tags: NestedValue + tags: NestedValue cover_image: NestedValue | string body: string } @@ -60,7 +60,7 @@ export default function QuestionForm() {
- ( @@ -71,7 +71,7 @@ export default function QuestionForm() { uploadText='Add a cover image' /> )} - /> + /> */}

{errors.cover_image?.message}

@@ -95,9 +95,18 @@ export default function QuestionForm() {

Tags

- ( + + )} /> {errors.tags &&

{errors.tags.message} diff --git a/src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm.stories.tsx b/src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm.stories.tsx index ea4d469..e89e15b 100644 --- a/src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm.stories.tsx +++ b/src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm.stories.tsx @@ -13,7 +13,6 @@ export default { decorators: [WithModals, WrapForm({ defaultValues: { tags: [], - cover_image: [], } })] } as ComponentMeta; diff --git a/src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm.tsx b/src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm.tsx index e18a6d2..fe60d0c 100644 --- a/src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm.tsx +++ b/src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { Controller, useFormContext } from "react-hook-form"; import Button from "src/Components/Button/Button"; -import FilesInput from "src/Components/Inputs/FilesInput/FilesInput"; import TagsInput from "src/Components/Inputs/TagsInput/TagsInput"; import ContentEditor from "../ContentEditor/ContentEditor"; import { useCreateStoryMutation } from 'src/graphql' @@ -13,7 +12,8 @@ import { createRoute } from 'src/utils/routing'; import PreviewPostCard from '../PreviewPostCard/PreviewPostCard' import { StorageService } from 'src/services'; import { useThrottledCallback } from '@react-hookz/web'; -import { CreateStoryType, IStoryFormInputs } from '../../CreateStoryPage/CreateStoryPage'; +import { CreateStoryType } from '../../CreateStoryPage/CreateStoryPage'; +import CoverImageInput from 'src/Components/Inputs/FilesInputs/CoverImageInput/CoverImageInput'; interface Props { isUpdating?: boolean; @@ -29,7 +29,7 @@ export default function StoryForm(props: Props) { const dispatch = useAppDispatch(); const navigate = useNavigate(); - const { handleSubmit, control, register, trigger, getValues, watch, reset } = useFormContext(); + const { handleSubmit, control, register, trigger, getValues, watch, reset } = useFormContext(); const [editMode, setEditMode] = useState(true) @@ -80,7 +80,7 @@ export default function StoryForm(props: Props) { refetchQueries: ['GetMyDrafts'] }); - const clickSubmit = (publish_now: boolean) => handleSubmit(data => { + const clickSubmit = (publish_now: boolean) => handleSubmit(data => { setLoading(true); createStory({ variables: { @@ -90,7 +90,7 @@ export default function StoryForm(props: Props) { body: data.body, tags: data.tags.map(t => t.title), is_published: publish_now, - cover_image: (data.cover_image[0] ?? null) as string | null, + cover_image: data.cover_image, }, } }) @@ -103,6 +103,8 @@ export default function StoryForm(props: Props) { const { ref: registerTitleRef, ...titleRegisteration } = register('title'); + + return ( <>

@@ -117,19 +119,21 @@ export default function StoryForm(props: Props) {
- ( - + { + onChange(e) + }} + // uploadText='Add a cover image' /> - )} - /> + + } + /> +
@@ -153,11 +157,21 @@ export default function StoryForm(props: Props) { />
- ( + + )} /> +
} - {!editMode && } + {!editMode && }