diff --git a/src/model/common.ts b/src/model/common.ts index 75444ff..a4ae09e 100644 --- a/src/model/common.ts +++ b/src/model/common.ts @@ -2,6 +2,7 @@ export interface Request{ id: number; text: string; files: File[]; + ratio: string; } export interface Response{ diff --git a/src/pages/Picture/Picture.module.css b/src/pages/Picture/Picture.module.css index e69de29..31c7e36 100644 --- a/src/pages/Picture/Picture.module.css +++ b/src/pages/Picture/Picture.module.css @@ -0,0 +1,540 @@ +.page { + display: grid; + min-height: 100vh; + padding: var(--space-6) var(--space-10) var(--space-10); + color: var(--color-text); + background: var(--color-bg); +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 52px; + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--color-border); +} + +.brand { + display: flex; + align-items: center; + min-width: 0; + gap: var(--space-3); +} + +.backButton, +.removeFileButton { + display: inline-grid; + flex: 0 0 auto; + place-items: center; + width: 36px; + height: 36px; + padding: 0; + color: var(--color-text); + cursor: pointer; + background: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + transition: + color var(--duration-fast) var(--ease-standard), + border-color var(--duration-fast) var(--ease-standard), + background-color var(--duration-fast) var(--ease-standard); +} + +.backButton { + font-size: 22px; + line-height: 1; +} + +.backButton:hover, +.removeFileButton:hover { + color: var(--color-black); + background: var(--color-primary); + border-color: var(--color-primary); +} + +.backButton:focus-visible, +.removeFileButton:focus-visible, +.uploadButton:focus-within, +.segmentedControl button:focus-visible, +.styleChoices button:focus-visible, +.generateButton:focus-visible { + outline: 3px solid var(--color-primary-soft); + outline-offset: 3px; +} + +.logo { + width: 150px; + max-height: 38px; + object-fit: contain; + object-position: left center; +} + +.mode, +.eyebrow, +.labelRow, +.previewHeader, +.previewFooter { + font-size: 12px; + font-weight: 800; + line-height: 1.2; + letter-spacing: 0; + text-transform: uppercase; +} + +.mode, +.eyebrow { + color: var(--color-primary); +} + +.workspace { + display: grid; + grid-template-columns: minmax(300px, 0.86fr) minmax(380px, 1.14fr); + gap: var(--space-6); + width: min(100%, 1120px); + margin: auto; + padding-block: var(--space-8); +} + +.controls, +.previewPanel { + min-width: 0; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); +} + +.controls { + display: flex; + flex-direction: column; + gap: var(--space-6); + padding: var(--space-8); +} + +.heading { + display: grid; + gap: var(--space-2); +} + +.eyebrow { + margin: 0; +} + +.heading h1 { + margin: 0; + font-size: clamp(30px, 4vw, 44px); + font-weight: 900; + line-height: 1; +} + +.field { + display: grid; + min-width: 0; + gap: var(--space-3); + padding: 0; + margin: 0; + border: 0; + font-weight: 800; +} + +.field legend { + padding: 0; + font-size: 14px; +} + +.labelRow { + display: flex; + justify-content: space-between; + gap: var(--space-3); + color: var(--color-text-muted); +} + +.labelRow span:first-child { + color: var(--color-text); +} + +.field textarea { + width: 100%; + min-height: 132px; + padding: var(--space-4); + resize: vertical; + color: var(--color-text); + font: inherit; + line-height: 1.45; + background: rgba(3, 6, 16, 0.42); + border: 1px solid rgba(216, 227, 251, 0.16); + border-radius: var(--radius-sm); + outline: 0; + transition: border-color var(--duration-fast) var(--ease-standard); +} + +.field textarea::placeholder { + color: var(--color-gray-500); +} + +.field textarea:focus { + border-color: var(--color-primary); +} + +.uploadButton, +.fileSelected { + display: flex; + align-items: center; + min-height: 48px; + padding: var(--space-2) var(--space-3); + background: rgba(3, 6, 16, 0.42); + border: 1px dashed rgba(216, 227, 251, 0.22); + border-radius: var(--radius-sm); +} + +.uploadButton { + justify-content: center; + font-size: 14px; + font-weight: 800; + color: var(--color-primary); + cursor: pointer; + transition: + color var(--duration-fast) var(--ease-standard), + border-color var(--duration-fast) var(--ease-standard), + background-color var(--duration-fast) var(--ease-standard); +} + +.uploadButton:hover { + color: var(--color-black); + background: var(--color-primary); + border-color: var(--color-primary); +} + +.uploadButton input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + +.fileSelected { + justify-content: space-between; + gap: var(--space-3); + border-style: solid; + border-color: rgba(143, 84, 255, 0.5); +} + +.fileName { + overflow: hidden; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.removeFileButton { + width: 30px; + height: 30px; + font-size: 20px; +} + +.segmentedControl, +.styleChoices { + display: grid; + gap: var(--space-2); +} + +.segmentedControl, +.styleChoices { + grid-template-columns: repeat(3, 1fr); +} + +.segmentedControl button, +.styleChoices button { + min-width: 0; + padding: var(--space-3); + font: inherit; + font-size: 14px; + font-weight: 800; + color: var(--color-text-muted); + cursor: pointer; + background: rgba(3, 6, 16, 0.42); + border: 1px solid rgba(216, 227, 251, 0.16); + border-radius: var(--radius-sm); + transition: + color var(--duration-fast) var(--ease-standard), + border-color var(--duration-fast) var(--ease-standard), + background-color var(--duration-fast) var(--ease-standard); +} + +.segmentedControl button:hover, +.styleChoices button:hover { + color: var(--color-text); + border-color: var(--color-accent); +} + +.segmentedControl .selected, +.styleChoices .styleSelected { + color: var(--color-text); + background: var(--color-primary-soft); + border-color: var(--color-primary); +} + +.styleChoices button { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); +} + +.swatch { + width: 14px; + height: 14px; + border-radius: 50%; +} + +.synthesis { + background: var(--color-primary); + box-shadow: 10px 0 0 var(--color-accent); +} + +.neon { + background: var(--color-accent); + box-shadow: 10px 0 0 var(--color-accent-warm); +} + +.cinema { + background: var(--color-accent-gold); + box-shadow: 10px 0 0 var(--color-accent-warm); +} + +.error { + padding: var(--space-3); + margin: 0; + font-size: 14px; + color: var(--color-rose-200); + background: rgba(190, 18, 60, 0.16); + border: 1px solid rgba(254, 205, 211, 0.36); + border-radius: var(--radius-sm); +} + +.generateButton { + min-height: 52px; + padding: var(--space-3) var(--space-5); + margin-top: auto; + font: inherit; + font-weight: 900; + color: var(--color-black); + cursor: pointer; + background: var(--color-primary); + border: 1px solid var(--color-primary); + border-radius: var(--radius-sm); + transition: + background-color var(--duration-fast) var(--ease-standard), + border-color var(--duration-fast) var(--ease-standard), + transform var(--duration-fast) var(--ease-standard); +} + +.generateButton:hover:not(:disabled) { + background: var(--color-primary-hover); + border-color: var(--color-primary-hover); + transform: translateY(-1px); +} + +.generateButton:disabled { + cursor: wait; + opacity: 0.66; +} + +.previewPanel { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; +} + +.previewHeader, +.previewFooter { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 56px; + padding-inline: var(--space-6); +} + +.previewHeader { + border-bottom: 1px solid var(--color-border); +} + +.previewFooter { + color: var(--color-text-muted); + border-top: 1px solid var(--color-border); +} + +.statusDot { + width: 9px; + height: 9px; + background: var(--color-gray-500); + border-radius: 50%; +} + +.statusDot[data-active="true"] { + background: var(--color-primary); + box-shadow: 0 0 0 5px var(--color-primary-soft); +} + +.previewArea { + display: grid; + min-height: 460px; + padding: var(--space-6); + overflow: auto; + place-items: center; + background: + linear-gradient(rgba(53, 216, 255, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(53, 216, 255, 0.04) 1px, transparent 1px), + rgba(3, 6, 16, 0.3); + background-size: 24px 24px; +} + +.previewCanvas { + position: relative; + display: grid; + width: min(100%, 560px); + overflow: hidden; + place-items: center; + background: #0a1430; + border: 1px solid rgba(53, 216, 255, 0.34); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); +} + +.square { + aspect-ratio: 1; +} + +.portrait { + width: min(80%, 430px); + aspect-ratio: 4 / 5; +} + +.wide { + aspect-ratio: 16 / 9; +} + +.referenceImage { + width: 100%; + height: 100%; + object-fit: cover; +} + +.emptyState { + display: grid; + justify-items: center; + gap: var(--space-4); + font-size: 13px; + font-weight: 800; + color: var(--color-text-muted); + letter-spacing: 0; + text-transform: uppercase; +} + +.emptyMark { + position: relative; + width: 74px; + height: 74px; + border: 2px solid var(--color-primary); + border-radius: var(--radius-sm); + transform: rotate(8deg); +} + +.emptyMark::before, +.emptyMark::after { + position: absolute; + content: ""; +} + +.emptyMark::before { + inset: 12px; + border: 2px solid var(--color-accent); + border-radius: 2px; +} + +.emptyMark::after { + right: -12px; + bottom: -12px; + width: 24px; + height: 24px; + background: var(--color-accent-warm); + border: 2px solid var(--color-bg); +} + +.scanLine { + position: absolute; + inset: 0 auto auto 0; + width: 100%; + height: 2px; + background: var(--color-primary); + box-shadow: 0 0 18px var(--color-primary); + animation: scan 1.8s ease-in-out infinite; +} + +@keyframes scan { + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(300px); + } +} + +@media (max-width: 820px) { + .page { + padding: var(--space-4) var(--space-6) var(--space-6); + } + + .workspace { + grid-template-columns: 1fr; + padding-block: var(--space-6); + } + + .controls { + padding: var(--space-6); + } + + .previewArea { + min-height: 360px; + } +} + +@media (max-width: 480px) { + .page { + padding-inline: var(--space-4); + } + + .logo { + width: 118px; + } + + .mode { + font-size: 10px; + } + + .controls { + gap: var(--space-5); + padding: var(--space-5); + } + + .styleChoices button { + padding-inline: var(--space-2); + font-size: 12px; + } + + .previewHeader, + .previewFooter { + padding-inline: var(--space-4); + } + + .previewArea { + min-height: 300px; + padding: var(--space-4); + } +} diff --git a/src/pages/Picture/Picture.tsx b/src/pages/Picture/Picture.tsx index 902ba1b..72efbbe 100644 --- a/src/pages/Picture/Picture.tsx +++ b/src/pages/Picture/Picture.tsx @@ -1,8 +1,208 @@ +import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react"; +import { useNavigate } from "react-router-dom"; +import logo from "../../assets/common/HITGENERATOR-Logo.png"; +import { useAppDispatch, useAppSelector } from "../../store/hooks"; +import { clearRequestError, request } from "../../store/slices/slice"; +import styles from "./Picture.module.css"; + +type AspectRatio = "1:1" | "4:5" | "16:9"; + +const aspectRatios: AspectRatio[] = ["1:1", "4:5", "16:9"]; export default function Picture() { - return ( - - Picture - - ); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const { loading, error: requestError } = useAppSelector((state) => state.request); + + const [prompt, setPrompt] = useState(""); + const [reference, setReference] = useState([]); + const [aspectRatio, setAspectRatio] = useState("1:1"); + const [formError, setFormError] = useState(null); + const [isSubmitted, setIsSubmitted] = useState(false); + + useEffect(() => { + dispatch(clearRequestError()); + }, [dispatch]); + + function handleReferenceChange(event: ChangeEvent) { + const nextFile = event.currentTarget.files?.[0] ?? null; + + if (nextFile && !nextFile.type.startsWith("image/")) { + setFormError("Выберите файл изображения."); + return; + } + + setFormError(null); + //updateReference(nextFile); + setIsSubmitted(false); + setReference(nextFile ? [...reference, nextFile] : []); + + } + + function removeReference(index: number) { + //updateReference(null); + setIsSubmitted(false); + setReference(reference.filter((_, i) => i !== index)); + + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + + const text = prompt.trim(); + + if (!text) { + setFormError("Добавьте описание картинки."); + return; + } + + setFormError(null); + setIsSubmitted(false); + + await dispatch( + request({ + id: Date.now(), + text, + files: reference ? reference : [], + ratio: aspectRatio, + }), + ).unwrap(); + setIsSubmitted(true); + + } + + const previewClassName = [ + styles.previewCanvas, + aspectRatio === "1:1" + ? styles.square + : aspectRatio === "4:5" + ? styles.portrait + : styles.wide, + ].join(" "); + const status = loading ? "Генерируем" : isSubmitted ? "Запрос принят" : "Ожидание"; + + return ( + + + + navigate("/")} + > + ← + + + + PICTURE + + + + + + IMAGE LAB + Генерация картинки + + + + + Описание + {prompt.length}/800 + + { + setFormError(null); + setPrompt(event.target.value); + setIsSubmitted(false); + }} + /> + + + + + Референс + Необязательно + + {reference.length > 0 && ( + reference.map((file, index) => ( + + {file.name} + removeReference(index)} + > + × + + + )) + )} + + + Выбрать файл + + + + + + Формат + + {aspectRatios.map((ratio) => ( + { + setAspectRatio(ratio); + setIsSubmitted(false); + }} + > + {ratio} + + ))} + + + + {(formError || requestError) && ( + + {formError || requestError} + + )} + + + {loading ? "Генерируем..." : "Сгенерировать"} + + + + + + {status} + + + + + + + + {loading ? "Генерируем" : isSubmitted ? "Запрос принят" : "Ожидаемое изображение"} + + {loading && } + + + + + + + + ); } \ No newline at end of file diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 66fb5f5..8e14dd4 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -1,5 +1,5 @@ :root { - color-scheme: light; + color-scheme: dark; --font-family-base: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
IMAGE LAB
+ {formError || requestError} +