diff --git a/LICENSE b/LICENSE index 3d6b955..d990cd8 100644 --- a/LICENSE +++ b/LICENSE @@ -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 diff --git a/locales/zh-CN/common.json b/locales/zh-CN/common.json index 51dd9fd..872ec2b 100644 --- a/locales/zh-CN/common.json +++ b/locales/zh-CN/common.json @@ -15,7 +15,8 @@ "deleteConfirm": "确定要删除这张图片吗?" }, "input": { - "placeholder": "请输入 Midjourney 提示词..." + "placeholder": "请输入 Midjourney 提示词...", + "uploadImage": "上传图片" }, "requestError": "请求失败,错误码 {{errorCode}}", "response": { diff --git a/src/app/api/files/image/route.ts b/src/app/api/files/image/route.ts new file mode 100644 index 0000000..5b35b1d --- /dev/null +++ b/src/app/api/files/image/route.ts @@ -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; +} diff --git a/src/app/home/index.tsx b/src/app/home/index.tsx index a55855a..9f89fdb 100644 --- a/src/app/home/index.tsx +++ b/src/app/home/index.tsx @@ -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'; diff --git a/src/app/iframe/page.tsx b/src/app/iframe/page.tsx index ee74ae0..3488e71 100644 --- a/src/app/iframe/page.tsx +++ b/src/app/iframe/page.tsx @@ -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'; diff --git a/src/config/server.ts b/src/config/server.ts index 3333216..d688eb7 100644 --- a/src/config/server.ts +++ b/src/config/server.ts @@ -5,10 +5,16 @@ 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'); @@ -16,5 +22,7 @@ export const getServerConfig = () => { return { MIDJOURNEY_PROXY_URL: process.env.MIDJOURNEY_PROXY_URL, + + IMGUR_CLIENT_ID: process.env.IMGUR_CLIENT_ID || DEFAULT_IMAGUR_CLIENT_ID, }; }; diff --git a/src/features/Preview/ImagePreview/ImagineAction.tsx b/src/features/Preview/ImagePreview/ImagineAction.tsx index 969832f..2c465fe 100644 --- a/src/features/Preview/ImagePreview/ImagineAction.tsx +++ b/src/features/Preview/ImagePreview/ImagineAction.tsx @@ -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'; @@ -121,7 +121,7 @@ const ImageAction = memo(({ setMask, id }) => { gap={4} glass horizontal - icon={Brush} + icon={SwatchBookIcon} onClick={(e) => { e.stopPropagation(); if (!id) return; diff --git a/src/features/PromptEditor/ReferenceImage.tsx b/src/features/PromptEditor/ReferenceImage.tsx new file mode 100644 index 0000000..7a60310 --- /dev/null +++ b/src/features/PromptEditor/ReferenceImage.tsx @@ -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(({ imageUploading, setImageUploading }) => { + const { styles } = useStyles(); + const [uploadImageUrl, uploadImage] = useMidjourneyStore((s) => [ + s.referenceImageUrl, + s.uploadImage, + ]); + const { t } = useTranslation('common'); + + return uploadImageUrl ? ( + { + e.stopPropagation(); + useMidjourneyStore.setState({ referenceImageUrl: undefined }); + }} + size={'small'} + /> + } + classNames={{ image: styles.image, wrapper: styles.imageWrapper }} + src={uploadImageUrl} + wrapperClassName={styles.imageWrapper} + /> + ) : ( + { + setImageUploading(true); + + try { + await uploadImage(file); + } catch {} + + setImageUploading(false); + return false; + }} + multiple={true} + showUploadList={false} + > + + + ); +}); + +export default ReferenceImage; diff --git a/src/features/Input.tsx b/src/features/PromptEditor/index.tsx similarity index 63% rename from src/features/Input.tsx rename to src/features/PromptEditor/index.tsx index cce0395..c696c5c 100644 --- a/src/features/Input.tsx +++ b/src/features/PromptEditor/index.tsx @@ -1,12 +1,14 @@ 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; @@ -14,10 +16,34 @@ const useStyles = createStyles(({ css, token, stylish, cx }) => ({ 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; @@ -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 ( +