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 &&
+
}
+ {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