Skip to content

Commit

Permalink
✨ feat: support image upload feature (#9)
Browse files Browse the repository at this point in the history
* ✨ feat: try with upload

* ✨ feat: try with upload

* 📝 docs: update docs

* ✨ feat: try with upload

* 🐛 fix: fix upload error

* ✨ feat: support upload with image

* 🎨 chore: clean code
  • Loading branch information
arvinxx authored Jan 20, 2024
1 parent cc44430 commit d3e2fe4
Show file tree
Hide file tree
Showing 12 changed files with 265 additions and 15 deletions.
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2023 - current LobeHub
Copyright (c) 2024 - current LobeHub

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
3 changes: 2 additions & 1 deletion locales/zh-CN/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"deleteConfirm": "确定要删除这张图片吗?"
},
"input": {
"placeholder": "请输入 Midjourney 提示词..."
"placeholder": "请输入 Midjourney 提示词...",
"uploadImage": "上传图片"
},
"requestError": "请求失败,错误码 {{errorCode}}",
"response": {
Expand Down
72 changes: 72 additions & 0 deletions src/app/api/files/image/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { getServerConfig } from '@/config/server';

export const runtime = 'edge';

const baseURL = 'https://api.imgur.com/3';

export const POST = async (req: Request) => {
const clientId = getServerConfig().IMGUR_CLIENT_ID;

const res = await fetch(`${baseURL}/upload`, {
body: await req.blob(),
headers: {
Authorization: `Client-ID ${clientId}`,
},
method: 'POST',
}).catch((error) => {
return new Response(JSON.stringify(error.cause), { status: 400 });
});

if (!res.ok) {
return res;
}

const data: UploadResponse = await res.json();

let url: string | undefined;
if (data.success) {
url = data.data.link;
}

if (!url) return new Response(JSON.stringify({ error: 'upload failed' }), { status: 500 });

return new Response(JSON.stringify({ url }));
};

interface UploadResponse {
data: UploadData;
status: number;
success: boolean;
}

interface UploadData {
account_id: any;
account_url: any;
ad_type: any;
ad_url: any;
animated: boolean;
bandwidth: number;
datetime: number;
deletehash: string;
description: any;
favorite: boolean;
has_sound: boolean;
height: number;
hls: string;
id: string;
in_gallery: boolean;
in_most_viral: boolean;
is_ad: boolean;
link: string;
mp4: string;
name: string;
nsfw: any;
section: any;
size: number;
tags: any[];
title: any;
type: string;
views: number;
vote: any;
width: number;
}
2 changes: 1 addition & 1 deletion src/app/home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { useTheme } from 'antd-style';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';

import PromptInput from '@/features/Input';
import ImagePreview from '@/features/Preview';
import PromptInput from '@/features/PromptEditor';
import TaskList from '@/features/TaskList';
import { useMidjourneyStore } from '@/store/midjourney';

Expand Down
2 changes: 1 addition & 1 deletion src/app/iframe/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';

import PromptInput from '@/features/Input';
import ImagePreview from '@/features/Preview';
import PromptInput from '@/features/PromptEditor';
import TaskList from '@/features/TaskList';
import { useMidjourneyStore } from '@/store/midjourney';

Expand Down
8 changes: 8 additions & 0 deletions src/config/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,24 @@ declare global {
namespace NodeJS {
interface ProcessEnv {
MIDJOURNEY_PROXY_URL?: string;

IMGUR_CLIENT_ID?: string;
}
}
}

// we apply a free imgur app to get a client id
// refs: https://apidocs.imgur.com/
const DEFAULT_IMAGUR_CLIENT_ID = 'e415f320d6e24f9';

export const getServerConfig = () => {
if (typeof process === 'undefined') {
throw new TypeError('[Server Config] you are importing a server-only module outside of server');
}

return {
MIDJOURNEY_PROXY_URL: process.env.MIDJOURNEY_PROXY_URL,

IMGUR_CLIENT_ID: process.env.IMGUR_CLIENT_ID || DEFAULT_IMAGUR_CLIENT_ID,
};
};
4 changes: 2 additions & 2 deletions src/features/Preview/ImagePreview/ImagineAction.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ActionIcon } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { Brush, Expand } from 'lucide-react';
import { Expand, SwatchBookIcon } from 'lucide-react';
import { rgba } from 'polished';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
Expand Down Expand Up @@ -121,7 +121,7 @@ const ImageAction = memo<ImageActionProps>(({ setMask, id }) => {
gap={4}
glass
horizontal
icon={Brush}
icon={SwatchBookIcon}
onClick={(e) => {
e.stopPropagation();
if (!id) return;
Expand Down
103 changes: 103 additions & 0 deletions src/features/PromptEditor/ReferenceImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { ActionIcon, Image } from '@lobehub/ui';
import { Upload } from 'antd';
import { createStyles } from 'antd-style';
import { FileImageIcon, Trash } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';

import { useMidjourneyStore } from '@/store/midjourney';

const useStyles = createStyles(({ css, token, stylish, cx }) => ({
container: css`
padding: 4px;
background: ${token.colorFillTertiary};
border: 1px solid ${token.colorBorderSecondary};
border-radius: ${token.borderRadiusLG}px;
`,
deleteButton: css`
color: #fff;
background: ${token.colorBgMask};
&:hover {
background: ${token.colorError};
}
`,
image: css`
width: 56px;
height: 56px;
`,
imageWrapper: css`
align-self: center;
width: 56px;
min-width: auto;
height: 56px;
min-height: auto;
margin-block: 0;
`,
prompt: cx(
stylish.noScrollbar,
css`
align-self: flex-start;
padding: 6px;
font-family: ${token.fontFamilyCode};
font-size: 13px;
line-height: 1.4 !important;
`,
),
}));

interface ReferenceImageProps {
imageUploading: boolean;
setImageUploading: (loading: boolean) => void;
}
const ReferenceImage = memo<ReferenceImageProps>(({ imageUploading, setImageUploading }) => {
const { styles } = useStyles();
const [uploadImageUrl, uploadImage] = useMidjourneyStore((s) => [
s.referenceImageUrl,
s.uploadImage,
]);
const { t } = useTranslation('common');

return uploadImageUrl ? (
<Image
actions={
<ActionIcon
className={styles.deleteButton}
glass
icon={Trash}
onClick={(e) => {
e.stopPropagation();
useMidjourneyStore.setState({ referenceImageUrl: undefined });
}}
size={'small'}
/>
}
classNames={{ image: styles.image, wrapper: styles.imageWrapper }}
src={uploadImageUrl}
wrapperClassName={styles.imageWrapper}
/>
) : (
<Upload
accept="image/*"
beforeUpload={async (file) => {
setImageUploading(true);

try {
await uploadImage(file);
} catch {}

setImageUploading(false);
return false;
}}
multiple={true}
showUploadList={false}
>
<ActionIcon icon={FileImageIcon} loading={imageUploading} title={t('input.uploadImage')} />
</Upload>
);
});

export default ReferenceImage;
41 changes: 35 additions & 6 deletions src/features/Input.tsx → src/features/PromptEditor/index.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,49 @@
import { ActionIcon, TextArea } from '@lobehub/ui';
import { Flex } from 'antd';
import { createStyles } from 'antd-style';
import { SendHorizontal } from 'lucide-react';
import { memo } from 'react';
import { Brush } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { midjourneySelectors, useMidjourneyStore } from '@/store/midjourney';

import ReferenceImage from './ReferenceImage';

const useStyles = createStyles(({ css, token, stylish, cx }) => ({
container: css`
padding: 4px;
background: ${token.colorFillTertiary};
border: 1px solid ${token.colorBorderSecondary};
border-radius: ${token.borderRadiusLG}px;
`,
deleteButton: css`
color: #fff;
background: ${token.colorBgMask};
&:hover {
background: ${token.colorError};
}
`,
image: css`
width: 56px;
height: 56px;
`,
imageWrapper: css`
align-self: center;
width: 56px;
min-width: auto;
height: 56px;
min-height: auto;
margin-block: 0;
`,
prompt: cx(
stylish.noScrollbar,
css`
align-self: flex-start;
padding: 6px;
font-family: ${token.fontFamilyCode};
font-size: 13px;
line-height: 1.4 !important;
Expand All @@ -27,16 +53,18 @@ const useStyles = createStyles(({ css, token, stylish, cx }) => ({

const PromptInput = memo(() => {
const { styles } = useStyles();
const [prompts, updatePrompts, createImagineTask, isLoading] = useMidjourneyStore((s) => [
const [prompts, isLoading, updatePrompts, createImagineTask] = useMidjourneyStore((s) => [
s.prompts,
midjourneySelectors.isCreatingTaskLoading(s),
s.updatePrompts,
s.createImagineTask,
midjourneySelectors.isCreatingTaskLoading(s),
]);
const { t } = useTranslation('common');
const [imageUploading, setImageUploading] = useState(false);

return (
<Flex align={'center'} className={styles.container} gap={8}>
<ReferenceImage imageUploading={imageUploading} setImageUploading={setImageUploading} />
<TextArea
autoSize={{ maxRows: 3, minRows: 1 }}
className={styles.prompt}
Expand All @@ -50,9 +78,10 @@ const PromptInput = memo(() => {
/>
<ActionIcon
active
icon={SendHorizontal}
loading={isLoading}
icon={Brush}
loading={isLoading || imageUploading}
onClick={() => createImagineTask()}
style={{ height: '100%' }}
/>
</Flex>
);
Expand Down
17 changes: 17 additions & 0 deletions src/services/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class FileService {
async uploadFile(file: File) {
const formData = new FormData();
formData.append('image', file);

const res = await fetch('/api/files/image', {
body: formData,
method: 'POST',
});

const { url } = await res.json();

return url as string;
}
}

export const fileService = new FileService();
Loading

0 comments on commit d3e2fe4

Please sign in to comment.