diff --git a/.example.env b/.example.env index f127ae2..3145c3e 100644 --- a/.example.env +++ b/.example.env @@ -1,4 +1,4 @@ -REPLICATE_API_KEY=r8_4VNdkbY6n9d9kuAiuyAa8gziouf98QC0ZOdBx +REPLICATE_API_KEY=r8_2vsTHdL2qcq6r7jJLiCbNnZTTROSwNA0iEK1H #console.log('Replicate API Key:', process.env.REPLICATE_API_KEY); # diff --git a/components/Header.tsx b/components/Header.tsx index 190a758..2b6bd37 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -9,7 +9,7 @@ export default function Header() { const user = userContext?.user; return ( -
+
-

+

- DESIGN+ AI Toolkit + DESIGN + AI Toolkit

@@ -34,18 +34,18 @@ export default function Header() { ) : ( <> - + - - + + - + )} diff --git a/components/InputForm.tsx b/components/InputForm.tsx new file mode 100644 index 0000000..eb3cd26 --- /dev/null +++ b/components/InputForm.tsx @@ -0,0 +1,261 @@ +import React, {useCallback, useState} from 'react'; +import LoadingDots from "./LoadingDots"; +import generatePhoto from "../pages/api/generate"; +import dotenv from "dotenv"; + +interface FormData { + width: number; + height: number; + prompt: string; + scheduler: string; + numInterferenceSteps: number; + refine: string; + lora_scale: number; + guidance_scale: number; + apply_watermark: boolean; + high_noise_frac: number; + negative_prompt: string; + prompt_strength: number; + num_inference_steps: number; + num_outputs: number; +} + +const InputForm: React.FC = () => { + const [formData, setFormData] = useState({ + numInterferenceSteps: 0, + width: 768, + height: 768, + prompt:'', + refine: 'expert_ensemble_refiner', + scheduler: 'K_EULER', + lora_scale: 0.6, + num_outputs: 1, + guidance_scale: 7.5, + apply_watermark: false, + high_noise_frac: 0.8, + negative_prompt: '', + prompt_strength: 0.8, + num_inference_steps: 25 + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [generatedPhoto, setGeneratedPhoto] = useState(undefined); + const [inputPrompt, setInputPrompt] = useState(''); + const [width, setWidth] = useState(512); + const [height, setHeight] = useState(512); + const [numOutputs, setNumOutputs] = useState(1); + const [scheduler, setScheduler] = useState('K_EULER'); + const [numInterferenceSteps, setNumInterferenceSteps] = useState(25); + const onButtonSubmit = useCallback((event: React.MouseEvent) => { + event.preventDefault(); + console.log('Form Data:', formData); + }, [ + formData, + ]); + + const onResponse = useCallback((data: any) => { + setLoading(false); + if (data.ok) { + setGeneratedPhoto(data.photoUrl); + } else { + setError(data.message); + } +}, [ + setLoading, + setGeneratedPhoto, + setError, + ]); + + + const handleChange = (event: React.ChangeEvent) => { + setFormData({ + ...formData, + [event.target.name]: event.target.type === 'number' ? + parseInt(event.target.value, 10) : event.target.value, + }); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + console.log('Form Data:', formData); + }; + const handleGeneratePhoto = async () => { + setLoading(true); + setError(null); + setGeneratedPhoto(undefined); + const response = await fetch('/api/generate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(formData), + }); + const data = await response.json(); + setLoading(false); + if (response.ok) { + setGeneratedPhoto(data.photoUrl); + } else { + setError(data.message); + } + }; + + // async function generatePhoto() { + // await new Promise((resolve) => setTimeout(resolve, 10)); + // if (!inputPrompt) { + // setError('Please enter a description for your image.'); + // return; + // } + // setLoading(true); + // setError(null); + // setGeneratedPhoto(undefined); + // const response = await fetch('/api/generate', { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json', + // }, + // body: JSON.stringify({ prompt: inputPrompt }), + // }); + // let data = await response.json(); + // setLoading(false); + // if (response.ok) { + // setGeneratedPhoto(data.photoUrl); + // } else { + // setError(data.message); + // } + // } + + return ( + //
+ //

Generate an Image

+ //

Enter a description for your image and click the button to generate + // it.

+ //
+ // {error &&
+ // Error: + // {error} + //
} + // {generatedPhoto && Generated} + //
+ + +
+
+
+
+ + +
+ +
+ + + {formData.width} +
+ +
+ + + {formData.height} +
+ + {/*
*/} + {/* */} + {/* */} + {/* {formData.num_outputs}*/} + {/*
*/} + +
+ + +
+ +
+ + + {formData.numInterferenceSteps} +
+ + +
+ +
+
+ {generatedPhoto && Generated} +
+
+) + ; +}; + +export default InputForm; diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx index 762c923..c2cd90c 100644 --- a/components/LoginForm.tsx +++ b/components/LoginForm.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useState} from 'react'; + const LoginForm = () => { const [formData, setFormData] = useState({ email: '', password: '' }); diff --git a/components/ModelComponent.tsx b/components/ModelComponent.tsx index 9f79d46..fddd08e 100644 --- a/components/ModelComponent.tsx +++ b/components/ModelComponent.tsx @@ -57,6 +57,7 @@ type ModelProps = { description: string; imageUrl: string; link: string; + category: string; }; @@ -90,6 +91,7 @@ const ModelList: React.FC = ({ models }) => { description={model.description} imageUrl={model.imageUrl} link={model.link} + category={model.category} /> ))} @@ -101,19 +103,26 @@ const ModelComponent: React.FC = () => { const [models, setModels] = React.useState([]); useEffect(() => { - // Načítanie modelov (simulácia) + // Load models (simulation) const mockModels: ModelProps[] = [ - { title: 'tencentarc/gfpgan', description: 'úprava fotiek a detailov tváre', imageUrl: 'https://i.imgur.com/lEVlLiw.png', link:"/restore" }, - { title: 'stable diffusion', description: 'generovanie fotiek z textu', imageUrl: "https://i.imgur.com/GEQ0PGSl.png",link:"/generate" }, - { title: 'nightmareai/real-esrgan', description: 'zväčšenie rozlíšenia fotky', imageUrl: "https://i.imgur.com/1H73uDC.png",link:"/realesrgan" }, - { title: 'lucataco/realistic-vision-v5', description: 'generovanie realistických fotiek z textu', imageUrl: "https://replicate.delivery/pbxt/eVMzXJerAzpqnErNJ9P4ncWmd2d3OkGA31DKhG3ElQhLMIbRA/output.png",link:"/realvision" }, + { title: 'tencentarc/gfpgan', description: 'úprava fotiek a zväčšanie rozlíšenia', imageUrl: 'https://i.imgur.com/lEVlLiw.png', link:"/restore", category:"Restore"}, + { title: 'stable diffusion', description: 'generácia fotiek z textu', imageUrl: "https://i.imgur.com/GEQ0PGSl.png",link:"/generate", category:"Generate" }, + { title: 'nightmareai/real-esrgan', description: 'zväčšenie rozlíšenia', imageUrl: "https://i.imgur.com/1H73uDC.png",link:"/realesrgan", category:"Restore" }, + { title: 'lucataco/realistic-vision-v5', description: 'generácia realistických fotiek z textu', imageUrl: "https://replicate.delivery/pbxt/eVMzXJerAzpqnErNJ9P4ncWmd2d3OkGA31DKhG3ElQhLMIbRA/output.png",link:"/realvision", category:"Generate" }, ]; setModels(mockModels); }, []); + // Filter models based on category + const restoreModels = models.filter(model => model.category === 'Restore'); + const generateModels = models.filter(model => model.category === 'Generate'); + return (
- +

Restore Models

+ +

Generate Models

+
); }; diff --git a/components/NadpisAI.tsx b/components/NadpisAI.tsx new file mode 100644 index 0000000..0c016fe --- /dev/null +++ b/components/NadpisAI.tsx @@ -0,0 +1,20 @@ +import React, {Component} from 'react'; +import SquigglyLines from "./SquigglyLines"; + +class NadpisAi extends Component { + render() { + return ( +
+

+ + + Umelá inteligencia + + na dosah ruky. +

+
+ ); + } +} + +export default NadpisAi; \ No newline at end of file diff --git a/components/RegisterForm.tsx b/components/RegisterForm.tsx index 8713947..ea50e61 100644 --- a/components/RegisterForm.tsx +++ b/components/RegisterForm.tsx @@ -1,5 +1,7 @@ import React, {useCallback, useState, ChangeEvent, FormEvent} from 'react'; + + const RegisterForm = () => { const [formData, setFormData] = useState({ email: '', password: '' }); diff --git a/package-lock.json b/package-lock.json index c6bf25c..239a6df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@upstash/ratelimit": "^0.3.8", "@upstash/redis": "^1.19.1", "@vercel/analytics": "^0.1.9-beta.4", + "appwrite": "^14.0.1", "bcryptjs": "^2.4.3", "dotenv": "^16.4.5", "framer-motion": "^8.2.4", @@ -25,6 +26,7 @@ "react-compare-slider": "^2.2.0", "react-countup": "^6.4.0", "react-dom": "18.2.0", + "react-image-crop": "^11.0.5", "react-uploader": "^3.3.0", "react-use-measure": "^2.1.1", "replicate": "^0.29.1", @@ -2884,6 +2886,15 @@ "node": ">= 8" } }, + "node_modules/appwrite": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/appwrite/-/appwrite-14.0.1.tgz", + "integrity": "sha512-ORlvfqVif/2K3qKGgGiGfMP33Zwm+xxB1fIC4Lm3sojOkDd8u8YvgKQO0Meq5UXb8Dc0Rl66Z7qlGBAfRQ04bA==", + "dependencies": { + "cross-fetch": "3.1.5", + "isomorphic-form-data": "2.0.0" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -3333,6 +3344,14 @@ "resolved": "https://registry.npmjs.org/countup.js/-/countup.js-2.3.2.tgz", "integrity": "sha512-dQ7F/CmKGjaO6cDfhtEXwsKVlXIpJ89dFs8PvkaZH9jBVJ2Z8GU4iwG/qP7MgY8qwr+1skbwR6qecWWQLUzB8Q==" }, + "node_modules/cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "dependencies": { + "node-fetch": "2.6.7" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -3967,6 +3986,27 @@ "whatwg-fetch": "^3.4.1" } }, + "node_modules/isomorphic-form-data": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-form-data/-/isomorphic-form-data-2.0.0.tgz", + "integrity": "sha512-TYgVnXWeESVmQSg4GLVbalmQ+B4NPi/H4eWxqALKj63KsUrcu301YDjBqaOw3h+cbak7Na4Xyps3BiptHtxTfg==", + "dependencies": { + "form-data": "^2.3.2" + } + }, + "node_modules/isomorphic-form-data/node_modules/form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, "node_modules/isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -4931,6 +4971,14 @@ "react": "^18.2.0" } }, + "node_modules/react-image-crop": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.0.5.tgz", + "integrity": "sha512-A/Y/kspOzki1zDL/bSgwWIY1X3CQ9F1QwpdnncWLBVAktnKfAZDIQnWmjXzuzEjZHDMsBlArytIcPBVi6DNklg==", + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-uploader": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/react-uploader/-/react-uploader-3.3.0.tgz", @@ -7913,6 +7961,15 @@ "picomatch": "^2.0.4" } }, + "appwrite": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/appwrite/-/appwrite-14.0.1.tgz", + "integrity": "sha512-ORlvfqVif/2K3qKGgGiGfMP33Zwm+xxB1fIC4Lm3sojOkDd8u8YvgKQO0Meq5UXb8Dc0Rl66Z7qlGBAfRQ04bA==", + "requires": { + "cross-fetch": "3.1.5", + "isomorphic-form-data": "2.0.0" + } + }, "arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -8216,6 +8273,14 @@ "resolved": "https://registry.npmjs.org/countup.js/-/countup.js-2.3.2.tgz", "integrity": "sha512-dQ7F/CmKGjaO6cDfhtEXwsKVlXIpJ89dFs8PvkaZH9jBVJ2Z8GU4iwG/qP7MgY8qwr+1skbwR6qecWWQLUzB8Q==" }, + "cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "requires": { + "node-fetch": "2.6.7" + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -8685,6 +8750,26 @@ "whatwg-fetch": "^3.4.1" } }, + "isomorphic-form-data": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-form-data/-/isomorphic-form-data-2.0.0.tgz", + "integrity": "sha512-TYgVnXWeESVmQSg4GLVbalmQ+B4NPi/H4eWxqALKj63KsUrcu301YDjBqaOw3h+cbak7Na4Xyps3BiptHtxTfg==", + "requires": { + "form-data": "^2.3.2" + }, + "dependencies": { + "form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + } + } + }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -9359,6 +9444,12 @@ "scheduler": "^0.23.0" } }, + "react-image-crop": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.0.5.tgz", + "integrity": "sha512-A/Y/kspOzki1zDL/bSgwWIY1X3CQ9F1QwpdnncWLBVAktnKfAZDIQnWmjXzuzEjZHDMsBlArytIcPBVi6DNklg==", + "requires": {} + }, "react-uploader": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/react-uploader/-/react-uploader-3.3.0.tgz", diff --git a/package.json b/package.json index ab88945..47ac753 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@upstash/ratelimit": "^0.3.8", "@upstash/redis": "^1.19.1", "@vercel/analytics": "^0.1.9-beta.4", + "appwrite": "^14.0.1", "bcryptjs": "^2.4.3", "dotenv": "^16.4.5", "framer-motion": "^8.2.4", @@ -26,6 +27,7 @@ "react-compare-slider": "^2.2.0", "react-countup": "^6.4.0", "react-dom": "18.2.0", + "react-image-crop": "^11.0.5", "react-uploader": "^3.3.0", "react-use-measure": "^2.1.1", "replicate": "^0.29.1", diff --git a/pages/api/generate.ts b/pages/api/generate.ts index 2e8e8c0..c8e27d2 100644 --- a/pages/api/generate.ts +++ b/pages/api/generate.ts @@ -4,6 +4,7 @@ import requestIp from "request-ip"; import {NextApiRequest, NextApiResponse} from "next"; import {Ratelimit} from "@upstash/ratelimit"; import redis from "../../utils/redis"; +import {dot} from "@tensorflow/tfjs"; dotenv.config() interface ExtendedNextApiRequest extends NextApiRequest { @@ -42,8 +43,10 @@ export default async function handler ( } } + + const replicate = new Replicate({ - auth: "r8_QODWLhOyB1bnz2kIpNnK740PcuHM1E94ZKCVw", // Moved API key to environment variable + auth: process.env.REPLICATE_API_KEY, userAgent: 'https://www.npmjs.com/package/create-replicate' }) const model = 'stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b' diff --git a/pages/api/predictions/[id].js b/pages/api/predictions/[id].js new file mode 100644 index 0000000..43c6c74 --- /dev/null +++ b/pages/api/predictions/[id].js @@ -0,0 +1,29 @@ + import dotenv from "dotenv"; + import Replicate from "replicate"; + +export default async function handler(req, res) { + const response = await fetch( + "https://api.replicate.com/v1/predictions/" + req.query.id, + { + headers: { + Authorization: "process.env.REPLICATE_API_TOKEN", + "Content-Type": "application/json", + }, + } + ); + + const replicate = new Replicate({ + auth: process.env.REPLICATE_API_KEY, + userAgent: 'https://www.npmjs.com/package/create-replicate' + }); + c + if (response.status !== 200) { + let error = await response.json(); + res.statusCode = 500; + res.end(JSON.stringify({ detail: error.detail })); + return; + } + + const prediction = await response.json(); + res.end(JSON.stringify(prediction)); +} \ No newline at end of file diff --git a/pages/api/predictions/index.js b/pages/api/predictions/index.js new file mode 100644 index 0000000..05068d9 --- /dev/null +++ b/pages/api/predictions/index.js @@ -0,0 +1,28 @@ +export default async function handler(req, res) { + const response = await fetch("https://api.replicate.com/v1/predictions", { + method: "POST", + headers: { + Authorization: process.env.REPLICATE_API_TOKEN, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + // Pinned to a specific version of Stable Diffusion + // See https://replicate.com/stability-ai/sdxl + version: "2b017d9b67edd2ee1401238df49d75da53c523f36e363881e057f5dc3ed3c5b2", + + // This is the text prompt that will be submitted by a form on the frontend + input: { prompt: req.body.prompt }, + }), + }); + + if (response.status !== 201) { + let error = await response.json(); + res.statusCode = 500; + res.end(JSON.stringify({ detail: error.detail })); + return; + } + + const prediction = await response.json(); + res.statusCode = 201; + res.end(JSON.stringify(prediction)); +} \ No newline at end of file diff --git a/pages/api/realesrgan.ts b/pages/api/realesrgan.ts index f1d136f..6be5a9a 100644 --- a/pages/api/realesrgan.ts +++ b/pages/api/realesrgan.ts @@ -5,8 +5,12 @@ import redis from "../../utils/redis"; import {Ratelimit} from "@upstash/ratelimit"; import {json} from "node:stream/consumers"; + import {model} from "@tensorflow/tfjs"; + import * as replicate from "replicate"; + import fetch from "node-fetch"; dotenv.config() + // interface ExtendedNextApiRequest extends NextApiRequest { // body: { // imageUrl: string; @@ -29,53 +33,85 @@ }) : undefined; - - export default async function handler ( - // req: ExtendedNextApiRequest, - req: NextApiRequest, - // res: NextApiResponse - res: NextApiResponse - ) { - // Rate Limiter Code - if (ratelimit) { - const identifier = requestIp.getClientIp(req); - const result = await ratelimit.limit(identifier!); - res.setHeader("X-RateLimit-Limit", result.limit); - res.setHeader("X-RateLimit-Remaining", result.remaining); - - if (!result.success) { - res - .status(429) - return; - } - } - - const replicate = new Replicate({ - auth: "r8_QODWLhOyB1bnz2kIpNnK740PcuHM1E94ZKCVw", // Moved API key to environment variable - userAgent: 'https://www.npmjs.com/package/create-replicate' - }) - const output = await replicate.run( - "nightmareai/real-esrgan:42fed1c4974146d4d2414e2be2c5277c7fcf05fcc3a73abf41610695738c1d7b", - { - input: { - image: req.body.imageUrl, - scale: 2, - face_enhance: false - } - } - ); - - const handleOutput = (output: any) => { - if (!output || !output.output) { - return { success: false, message: "Failed to generate photo. Please try again later." }; - } - const photoUrl = output.output; - return { photoUrl }; - } - - const photoUrl = handleOutput(output); - res.status(200).json(photoUrl); - } + // + // export default async function handler ( + // // req: ExtendedNextApiRequest, + // req: NextApiRequest, + // // res: NextApiResponse + // res: NextApiResponse + // ) { + // // Rate Limiter Code + // if (ratelimit) { + // const identifier = requestIp.getClientIp(req); + // const result = await ratelimit.limit(identifier!); + // res.setHeader("X-RateLimit-Limit", result.limit); + // res.setHeader("X-RateLimit-Remaining", result.remaining); + // + // if (!result.success) { + // res + // .status(429) + // return; + // } + // } + // + // const replicate = new Replicate({ + // auth: process.env.REPLICATE_API_KEY, + // userAgent: 'https://www.npmjs.com/package/create-replicate' + // }) + // const output = await replicate.run( + // "nightmareai/real-esrgan:42fed1c4974146d4d2414e2be2c5277c7fcf05fcc3a73abf41610695738c1d7b", + // { + // input: { + // image: req.body.imageUrl, + // scale: 2, + // face_enhance: false + // } + // } + // ); + // + // const handleOutput = (output: any) => { + // if (!output || !output.output) { + // return { success: false, message: "Failed to generate photo. Please try again later." }; + // } + // const photoUrl = output.output; + // return { photoUrl }; + // } + // + // const photoUrl = handleOutput(output); + // res.status(200).json(photoUrl); + // } + // + // export default async function handler( + // req: NextApiRequest, + // res: NextApiResponse) { + // // + // const response = await fetch("https://api.replicate.com/v1/predictions" + req.query.id, { + // method: "POST", + // headers: { + // "Content-Type": "application/json", + // Authorization: "process.env.REPLICATE_API_TOKEN", + // }, + // body: JSON.stringify({ + // version: "42fed1c4974146d4d2414e2be2c5277c7fcf05fcc3a73abf41610695738c1d7b", + // input: { + // image: req.body.imageUrl, + // scale: 2, + // face_enhance: false + // } + // }), + // }); + // + // if (response.status !== 201) { + // let error = await response.json(); + // res.statusCode = 500; + // res.end(JSON.stringify({ detail: error.detail })); + // return; + // } + // + // const prediction = await response.json(); + // res.statusCode = 201; + // res.end(JSON.stringify(prediction)); + // } @@ -91,3 +127,35 @@ // // const photoUrl = handleOutput(output); // res.status(200).json(photoUrl); + + export default async function handler( + req:NextApiRequest, + res:NextApiResponse + ) { + const replicate = new Replicate({ + auth: process.env.REPLICATE_API_KEY, + userAgent: 'https://www.npmjs.com/package/create-replicate' + }) + const model = 'nightmareai/real-esrgan:350d32041630ffbe63c8352783a26d94126809164e54085352f8326e53999085' + const { image, scale, faceEnhance } = req.body; + try { + const output = await replicate.run( + model, + { + input: { + image, + scale, + face_enhance: faceEnhance, + }, + } + ); + + res.status(200).json(output); + } catch (error) { + console.error(error); + res.status(500).json({message: "Failed to generate photo. Please try again later."}); + } + + } + + diff --git a/pages/api/realvision.ts b/pages/api/realvision.ts index c7de0c8..f9a173d 100644 --- a/pages/api/realvision.ts +++ b/pages/api/realvision.ts @@ -41,7 +41,7 @@ export default async ( } } const replicate = new Replicate({ - auth: "r8_QODWLhOyB1bnz2kIpNnK740PcuHM1E94ZKCVw", // Moved API key to environment variable + auth: process.env.REPLICATE_API_KEY, userAgent: 'https://www.npmjs.com/package/create-replicate' }) const model = 'lucataco/realistic-vision-v5:8aeee50b868f06a1893e3b95a8bb639a8342e846836f3e0211d6a13c158505b1' diff --git a/pages/api/restore.ts b/pages/api/restore.ts index 1af86b9..245f7b4 100644 --- a/pages/api/restore.ts +++ b/pages/api/restore.ts @@ -8,53 +8,99 @@ import dotenv from "dotenv"; dotenv.config(); -type Data = string; -interface ExtendedNextApiRequest extends NextApiRequest { - body: { - imageUrl: string; - }; -} - -// Create a new ratelimiter, that allows 3 requests per day -const ratelimit = redis - ? new Ratelimit({ - redis: redis, - limiter: Ratelimit.fixedWindow(3, "1440 m"), - analytics: true, - }) - : undefined; - +// interface ExtendedNextApiRequest extends NextApiRequest { +// body: { +// imageUrl: string; +// }; +// } +// +// // Create a new ratelimiter, that allows 3 requests per day +// const ratelimit = redis +// ? new Ratelimit({ +// redis: redis, +// limiter: Ratelimit.fixedWindow(3, "1440 m"), +// analytics: true, +// }) +// : undefined; +// +// interface Data { +// success: boolean; +// photoUrl?: string; // Optional in case of errors +// message?: string; +// } +// +// export default async function handler( +// req: ExtendedNextApiRequest, +// res: NextApiResponse +// ) { +// // Rate Limiter Code +// if (ratelimit) { +// const identifier = requestIp.getClientIp(req); +// const result = await ratelimit.limit(identifier!); +// res.setHeader("X-RateLimit-Limit", result.limit); +// res.setHeader("X-RateLimit-Remaining", result.remaining); +// +// if (!result.success) { +// res +// res.status(429).json({success: false, message: "Rate limit exceeded. Please try again later."}); +// return; +// } +// } +// +// const imageUrl = req.body.imageUrl; +// const replicate = new Replicate({ +// auth: process.env.REPLICATE_API_KEY, +// userAgent: 'https://www.npmjs.com/package/create-replicate' +// }) +// const model = 'tencentarc/gfpgan:0fbacf7afc6c144e5be9767cff80f25aff23e52b0708f17e20f9879b2f21516c' +// const input = { +// img: imageUrl, +// version: "v1.4", +// scale: 2, +// } +// console.log({model, input}) +// const output = await replicate.run(model, {input}) as string[]; +// const handleOutput = (output: string[]) => { +// if (!output || output.length === 0) { +// return {success: false, message: "Failed to generate photo. Please try again later."}; +// } +// const photoUrl = output[0]; +// return {success: true, photoUrl}; +// } +// const result = handleOutput(output); +// res.status(200).json(result); +// } +// +// export default async function handler( - req: ExtendedNextApiRequest, - res: NextApiResponse + req:NextApiRequest, + res:NextApiResponse ) { - // Rate Limiter Code - if (ratelimit) { - const identifier = requestIp.getClientIp(req); - const result = await ratelimit.limit(identifier!); - res.setHeader("X-RateLimit-Limit", result.limit); - res.setHeader("X-RateLimit-Remaining", result.remaining); - - if (!result.success) { - res - .status(429) - .json("Too many uploads in 1 day. Please try again after 24 hours."); - return; - } - } - - const imageUrl = req.body.imageUrl; const replicate = new Replicate({ - auth: "r8_QODWLhOyB1bnz2kIpNnK740PcuHM1E94ZKCVw", // Moved API key to environment variable + auth: process.env.REPLICATE_API_KEY, userAgent: 'https://www.npmjs.com/package/create-replicate' }) const model = 'tencentarc/gfpgan:0fbacf7afc6c144e5be9767cff80f25aff23e52b0708f17e20f9879b2f21516c' - const input = { - img: imageUrl, - version: "v1.4", - scale: 2, + const { img, version, scale } = req.body; + try { + const output = await replicate.run( + model, + { + input: { + img, + version, + scale + }, + } + ); + + res.status(200).json(output); + } catch (error) { + console.error(error); + res.status(500).json({message: "Failed to generate photo. Please try again later."}); } - console.log({model, input}) + +} // The rest of the code that interacts with the Replicate API is commented out. // If needed, it should be updated to use the new API key from the environment variable as well. @@ -80,4 +126,3 @@ export default async function handler( // await new Promise((resolve) => setTimeout(resolve, 1000)); // } // } -} \ No newline at end of file diff --git a/pages/generate.tsx b/pages/generate.tsx index 488ac67..8783d86 100644 --- a/pages/generate.tsx +++ b/pages/generate.tsx @@ -14,104 +14,135 @@ import appendNewToName from "../utils/appendNewToName"; import downloadPhoto from "../utils/downloadPhoto"; import va from "@vercel/analytics"; import GeneratedPhoto from "../components/GeneratedPhoto"; +import InputForm from "../components/InputForm"; -const Home = () =>{ - - const [generatedPhoto, setGeneratedPhoto] = useState(undefined); // State for generated photo URL - const [loading, setLoading] = useState(false); - const [restoredLoaded, setRestoredLoaded] = useState(false); - const [sideBySide, setSideBySide] = useState(false); - const [error, setError] = useState(null); - const [photoName, setPhotoName] = useState(null); - const [inputPrompt, setInputPrompt] = useState(''); // State for text prompt - - async function generatePhoto() { - await new Promise((resolve) => setTimeout(resolve, 10)); - if (!inputPrompt) { - setError('Please enter a description for your image.'); - return; - } - setLoading(true); - setError(null); - setGeneratedPhoto(undefined); - const response = await fetch('/api/generate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ prompt: inputPrompt }), - }); - let data = await response.json(); - setLoading(false); - if (response.ok) { - setGeneratedPhoto(data.photoUrl); - } else { - setError(data.message); +const Home = () => { + + + + // interface FormData { + // width: number; + // height: number; + // prompt: string; + // scheduler: string; + // numInterferenceSteps: number; + // refine: string; + // lora_scale: number; + // guidance_scale: number; + // apply_watermark: boolean; + // high_noise_frac: number; + // negative_prompt: string; + // prompt_strength: number; + // num_inference_steps: number; + // num_outputs: number; + // } + + // const InputForm: React.FC = () => { + // const [formData, setFormData] = useState({ + // numInterferenceSteps: 0, + // width: 768, + // height: 768, + // prompt: '', + // refine: 'expert_ensemble_refiner', + // scheduler: 'K_EULER', + // lora_scale: 0.6, + // num_outputs: 1, + // guidance_scale: 7.5, + // apply_watermark: false, + // high_noise_frac: 0.8, + // negative_prompt: '', + // prompt_strength: 0.8, + // num_inference_steps: 25 + // }); + + const [generatedPhoto, setGeneratedPhoto] = useState(undefined); // State for generated photo URL + const [loading, setLoading] = useState(false); + const [restoredLoaded, setRestoredLoaded] = useState(false); + const [sideBySide, setSideBySide] = useState(false); + const [error, setError] = useState(null); + const [inputPrompt, setInputPrompt] = useState(''); // State for text prompt + const [width, setWidth] = useState(512); + const [height, setHeight] = useState(512); + const [numOutputs, setNumOutputs] = useState(1); + const [scheduler, setScheduler] = useState('K_EULER'); + const [numInterferenceSteps, setNumInterferenceSteps] = useState(25); + + + // const handleChange = (event: React.ChangeEvent) => { + // setFormData({ + // ...formData, + // [event.target.name]: event.target.type === 'number' ? + // parseInt(event.target.value, 10) : event.target.value, + // }); + // }; + + + // async function generatePhoto() { + // await new Promise((resolve) => setTimeout(resolve, 10)); + // if (!inputPrompt) { + // setError('Please enter a description for your image.'); + // return; + // } + // setLoading(true); + // setError(null); + // setGeneratedPhoto(undefined); + // const response = await fetch('/api/generate', { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json', + // }, + // body: JSON.stringify({prompt: inputPrompt}), + // }); + // let data = await response.json(); + // setLoading(false); + // if (response.ok) { + // setGeneratedPhoto(data.photoUrl); + // } else { + // setError(data.message); + // } + // } + + function onImageLoadError() { + setError('Failed to load image.'); + setGeneratedPhoto(undefined); } - } - - function onImageLoadError() { - setError('Failed to load image.'); - setGeneratedPhoto(undefined); - } - - return ( -
- - Generate Photos - - -
-
- - - - -
- {generatedPhoto ? ( - - Generated Image setRestoredLoaded(true)} - onError={onImageLoadError} - /> - - ) : null} - {error &&

{error}

} - setInputPrompt(e.target.value)} - className="w-full border border-gray-300 rounded-lg px-4 py-2 mb-5" - /> - - - -
-
-
-
-
-
-
- - - ); + + return ( +
+ + Generate Photos + + +
+
+ + + +
+
+

+ Vyskúšajte svetoznámy model generovania fotiek Stable Diffusion +

+

+Vložte textový popis a model vygeneruje fotografiu na základe vášho popisu. +

+ +
+ +
+
+ +
+
+
+
+
+ + + ); + + } export default Home; \ No newline at end of file diff --git a/pages/index.tsx b/pages/index.tsx index d161c86..fe95e51 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -7,6 +7,7 @@ import Header from "../components/Header"; import SquigglyLines from "../components/SquigglyLines"; import { Testimonials } from "../components/Testimonials"; import ModelComponent from "../components/ModelComponent"; +import NadpisAI from "../components/NadpisAI"; const Home: NextPage = () => { // const models = [ @@ -15,7 +16,7 @@ const Home: NextPage = () => { // // Add more models here... // ]; return ( -
+
DESGIN + AI Toolkit @@ -23,14 +24,7 @@ const Home: NextPage = () => {
-

- {" "} - - - Umelá
inteligencia
-
{" "} - na dosah ruky. -

+ { const [email, setEmail] = useState('') @@ -45,55 +46,58 @@ const Home: NextPage = () => {
+
+ -
-
+
+
-

- Sign in to your account -

-
+

+ Prihláste sa do svojho účtu +

+
-
-
-
- - - - -
-
+
+
+
+ + + + +
+
-

- Not a member?{' '} - - Start a 14 day free trial - -

-
+

+ Nie ste u nás registrovaný ?{' '} + + Zaregistrujte sa tu a získajte zdarma 30 kreditov.{' '} + +

+
-
+
+
- ) +) } diff --git a/pages/realesrgan.tsx b/pages/realesrgan.tsx index 266b739..5e01f63 100644 --- a/pages/realesrgan.tsx +++ b/pages/realesrgan.tsx @@ -38,6 +38,7 @@ const Home: NextPage = () => { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [photoName, setPhotoName] = useState(null); + const [isUploaded, setIsUploaded] = useState(false); const UploadDropZone = () => ( { options={options} onUpdate={(file) => { if (file.length !== 0) { - setPhotoName(file[0].originalFile.originalFileName); setOriginalPhoto(file[0].fileUrl.replace("raw", "thumbnail")); generatePhoto(file[0].fileUrl.replace("raw", "thumbnail")); + setIsUploaded(true); } }} width="670px" height="250px" + /> ); + function deleteImage() { + setOriginalPhoto(null); + setUpscaledPhoto(null); + setIsUploaded(false); + } // async function generatePhoto(fileUrl: string) { @@ -78,44 +85,101 @@ const Home: NextPage = () => { // setError(data.message); // } // } + + + // async function generatePhoto(fileUrl: string) { + // setLoading(true); + // setError(null); + // setUpscaledPhoto(null); + // const response = await fetch('/api/realesrgan', { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json', + // }, + // body: JSON.stringify({ input: { image: fileUrl, scale: 2, face_enhance: false }}), + // }); + // console.log(response) + // if (!response.ok) { + // setLoading(false); + // setError('Error: ' + response.statusText); + // return; + // } + // + // let data = await response.json(); + // setLoading(false); + // if (data.success) { + // setUpscaledPhoto(data.output); + // } else { + // setError(data.message); + // } + // } +// async function generatePhoto(fileUrl: string) { +// fetch('/api/realesrgan', { +// method: 'POST', +// headers: {'Content-Type': 'application/json'}, +// body: JSON.stringify({ +// image: fileUrl, +// scale: 2, +// faceEnhance: false, +// }), +// }) +// .then(response => response.json()) +// .then(output => console.log(output)) +// .catch(error => console.error(error)); +// } async function generatePhoto(fileUrl: string) { setLoading(true); setError(null); - setUpscaledPhoto(null); const response = await fetch('/api/realesrgan', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ input: { image: fileUrl, scale: 2, face_enhance: false }}), + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + image: fileUrl, + scale: 2, + faceEnhance: false, + }), }); - console.log(response) - if (!response.ok) { - setLoading(false); - setError('Error: ' + response.statusText); - return; - } - - let data = await response.json(); + const output = await response.json(); + console.log(output) setLoading(false); - if (data.success) { - setUpscaledPhoto(data.output); + if (response.ok) { + setUpscaledPhoto(output); + console.log(output) } else { - setError(data.message); + setError(output.message); } } - - return (
Real-ESRGAN - + -
-
- +
+ {/*
*/} + {/* */} +

Nechajte si zväčšiť rozlíšenie vašej fotografie behom pár sekúnd

+

Jednoducho nahrajte fotografiu ktorej chcete zväčšiť počet pixelov a následne si ju môžte stiahnuť.

+
+ {isUploaded ? ( + <> + + {upscaledPhoto && } + + ) : ( + + { + if (file.length !== 0) { + setOriginalPhoto(file[0].fileUrl.replace("raw", "thumbnail")); + generatePhoto(file[0].fileUrl.replace("raw", "thumbnail")); + setIsUploaded(true); + } + + }} width="670px" height="250px" + /> + )} @@ -140,7 +204,7 @@ const Home: NextPage = () => {
{loading && (
- +
)} {upscaledPhoto && ( @@ -170,11 +234,10 @@ const Home: NextPage = () => { -
+
); } - export default Home; \ No newline at end of file diff --git a/pages/realvision.tsx b/pages/realvision.tsx index 9f56604..285d63a 100644 --- a/pages/realvision.tsx +++ b/pages/realvision.tsx @@ -71,9 +71,11 @@ const Home = () => { ) : null} {error &&

{error}

} +

Generácia reálnych fotiek ľudí.

+

Daný model umelej inteligencie pracuje pomalšie a preto je potrebné na vygenerovanú fotku počkať o niečo dlhšie. Niekedy to môže trvať až 2 či 3 minúty na načítanie obrázku

setInputPrompt(e.target.value)} className="w-full border border-gray-300 rounded-lg px-4 py-2 mb-5" @@ -89,7 +91,7 @@ const Home = () => { - ) : 'Generate Image'} + ) : 'Generovať'}
diff --git a/pages/register.tsx b/pages/register.tsx index f5e3926..22de931 100644 --- a/pages/register.tsx +++ b/pages/register.tsx @@ -8,6 +8,7 @@ import SquigglyLines from "../components/SquigglyLines"; import { Testimonials } from "../components/Testimonials"; import ModelComponent from "../components/ModelComponent"; import RegisterForm from "../components/RegisterForm"; +import NadpisAI from "../components/NadpisAI"; const Home: NextPage = () => { // const models = [ @@ -24,14 +25,7 @@ const Home: NextPage = () => {
- {/*

*/} - {/* {" "}*/} - {/* */} - {/* */} - {/* Umelá
inteligencia
*/} - {/*
{" "}*/} - {/* na dosah ruky.*/} - {/*

*/} +
diff --git a/pages/restore.tsx b/pages/restore.tsx index a9d4169..9d29314 100644 --- a/pages/restore.tsx +++ b/pages/restore.tsx @@ -2,7 +2,7 @@ import { AnimatePresence, motion } from "framer-motion"; import { NextPage } from "next"; import Head from "next/head"; import Image from "next/image"; -import { useState } from "react"; +import React, { useState } from "react"; import CountUp from "react-countup"; import { UploadDropzone } from "react-uploader"; import { Uploader } from "uploader"; @@ -50,6 +50,9 @@ const Home: NextPage = () => { const [sideBySide, setSideBySide] = useState(false); const [error, setError] = useState(null); const [photoName, setPhotoName] = useState(null); + const [isUploaded, setIsUploaded] = useState(false); + + const UploadDropZone = () => ( { options={options} onUpdate={(file) => { if (file.length !== 0) { - setPhotoName(file[0].originalFile.originalFileName); setOriginalPhoto(file[0].fileUrl.replace("raw", "thumbnail")); generatePhoto(file[0].fileUrl.replace("raw", "thumbnail")); + setIsUploaded(true); } }} width="670px" @@ -67,24 +70,32 @@ const Home: NextPage = () => { /> ); + function deleteImage() { + setOriginalPhoto(null); + setRestoredImage(null); + setIsUploaded(false); + } async function generatePhoto(fileUrl: string) { - await new Promise((resolve) => setTimeout(resolve, 5000)); setLoading(true); - const res = await fetch("/api/restore", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ imageUrl: fileUrl }), + setError(null); + const response = await fetch('/api/restore', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + img: fileUrl, + version: "v1.4", + scale: 2 + }), }); - - let newPhoto = await res.json(); - if (res.status !== 200) { - setError(newPhoto); + const output = await response.json(); + console.log(output) + setLoading(false); + if (response.ok) { + setRestoredImage(output); + console.log(output) } else { - setRestoredImage(newPhoto); + setError(output.message); } - setLoading(false); } return ( @@ -96,26 +107,21 @@ const Home: NextPage = () => {
- - Are you a developer and want to learn how I built this? Watch the{" "} - YouTube tutorial. - +

- Restore any face photo + Upravte svoje fotografie pomocou umeléj inteligencie.

- {" "} - {/* Obtained this number from Vercel: based on how many serverless invocations happened. */} - {" "} - photos generated and counting. + Nahrajte svoje fotografie a nechajte AI obnoviť ich kvalitu. AI je trénované na obnovu starých fotografií, odstránenie šumu a zlepšenie črtov tváre.

+ {/*

*/} + {/* {" "}*/} + {/* /!* Obtained this number from Vercel: based on how many serverless invocations happened. *!/*/} + {/* {" "}*/} + {/* photos generated and counting.*/} + {/*

*/} - + { alt="original photo" src={originalPhoto} className="rounded-2xl" - width={475} - height={475} + width={512} + height={512} /> )} {restoredImage && originalPhoto && !sideBySide && ( @@ -146,22 +152,23 @@ const Home: NextPage = () => { alt="original photo" src={originalPhoto} className="rounded-2xl relative" - width={475} - height={475} + width={512} + height={512} />
)} diff --git a/pages/test.tsx b/pages/test.tsx new file mode 100644 index 0000000..994e880 --- /dev/null +++ b/pages/test.tsx @@ -0,0 +1,87 @@ +import { useState, FormEvent } from "react"; +import Head from "next/head"; +import Image from "next/image"; +import styles from "../styles/Home.module.css"; + +interface Prediction { + id: string; + output: string[]; + status: string; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export default function Home() { + const [prediction, setPrediction] = useState(null); + const [error, setError] = useState(null); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + const response = await fetch("/api/predictions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + prompt: e.currentTarget.prompt.value, + }), + }); + let prediction = await response.json(); + if (response.status !== 201) { + setError(prediction.detail); + return; + } + setPrediction(prediction); + + while ( + prediction.status !== "succeeded" && + prediction.status !== "failed" + ) { + await sleep(1000); + const response = await fetch("/api/predictions/" + prediction.id); + prediction = await response.json(); + if (response.status !== 200) { + setError(prediction.detail); + return; + } + console.log({prediction}) + setPrediction(prediction); + } + }; + + return ( +
+ + Replicate + Next.js + + +

+ Dream something with{" "} + SDXL: +

+ +
+ + +
+ + {error &&
{error}
} + + {prediction && ( +
+ {prediction.output && ( +
+ output +
+ )} +

status: {prediction.status}

+
+ )} +
+ ); +} \ No newline at end of file diff --git a/styles/Home.module.css b/styles/Home.module.css new file mode 100644 index 0000000..3eaf32a --- /dev/null +++ b/styles/Home.module.css @@ -0,0 +1,35 @@ +.container { + padding: 2rem; + font-size: 1.3rem; + max-width: 48rem; + margin: 0 auto; +} + +.form { + display: flex; + margin-bottom: 2rem; +} + +.form input { + width: 100%; + padding: 1rem; + border: 1px solid #000; + border-radius: 0.25rem; + font-size: 1.3rem; + margin-right: 1rem; +} + +.form button { + padding: 1rem; + border: none; + border-radius: 0.25rem; + box-sizing: border-box; + cursor: pointer; + font-size: 1.3rem; +} + +.imageWrapper { + width: 100%; + aspect-ratio: 1 / 1; + position: relative +} \ No newline at end of file diff --git a/styles/globals.css b/styles/globals.css index 98f5bd6..1796787 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -6,4 +6,86 @@ .bold-title { font-weight: bold; -} \ No newline at end of file +} + + +form { + background-color: #fff; + padding: 20px; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + font-family: 'Roboto', sans-serif; +} + +input[type="email"], +input[type="password"] { + width: 100%; + padding: 12px; + margin-bottom: 15px; + border: 1px solid #ddd; + border-radius: 3px; + box-sizing: border-box; +} + +button[type="submit"] { + background-color: #007bff; /* Modrá ako primárna farba */ + color: #fff; + border: none; + padding: 10px 20px; + border-radius: 3px; + cursor: pointer; + transition: background-color 0.3s ease; +} + +button[type="submit"]:hover { + background-color: #0056b3; /* Tmavšia modrá pri hover */ +} + +/* Register form - dodatočné štyly */ +.form-group { + margin-bottom: 15px; +} + +label { + display: block; + margin-bottom: 5px; +} + +input[type="text"] { + width: 100%; + padding: 12px; + margin-bottom: 15px; + border: 1px solid #ddd; + border-radius: 3px; + box-sizing: border-box; +} + +input[type="password"] { + width: 100%; + padding: 12px; + margin-bottom: 15px; + border: 1px solid #ddd; + border-radius: 3px; + box-sizing: border-box; +} + +/* Alert */ +.alert { + padding: 20px; + background-color: #f44336; /* Červená */ + color: white; + margin-bottom: 15px; +} + +/* Success */ + +.success { + background-color: #4CAF50; /* Zelená */ +} + +/* Info */ + +.info { + background-color: #2196F3; /* Modrá */ +} +