5.4.0: Картинка желания по ссылке и единая высота инпутов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s

This commit is contained in:
poignatov
2026-02-24 17:06:44 +03:00
parent ad52cf93ea
commit 7bbd732d72
4 changed files with 215 additions and 12 deletions

View File

@@ -71,6 +71,14 @@
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
/* Одинаковая высота всех инпутов на экране */
.wishlist-form .form-input {
height: 2.75rem;
box-sizing: border-box;
padding-top: 0;
padding-bottom: 0;
}
.image-preview {
position: relative;
width: 100%;
@@ -108,6 +116,105 @@
background: rgba(192, 57, 43, 1);
}
.image-url-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
}
.image-url-row + .image-url-row {
margin-top: 0.25rem;
}
.image-url-label {
font-size: 0.9rem;
color: #6b7280;
flex-shrink: 0;
width: 3.5rem;
display: inline-block;
}
.image-url-input {
flex: 1;
min-width: 120px;
}
.wishlist-form .file-input-label {
height: 2.75rem;
box-sizing: border-box;
}
.file-input-label {
flex: 1;
position: relative;
display: flex;
align-items: stretch;
justify-content: flex-start;
padding: 0 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
cursor: pointer;
transition: border-color 0.2s;
}
.file-input-label:hover {
border-color: #3498db;
}
.file-input-hidden {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.file-input-button {
font-size: 1rem;
line-height: 1;
color: #9ca3af;
pointer-events: none;
display: flex;
align-items: center;
justify-content: flex-start;
height: 100%;
}
.image-url-load-button {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
background: #3498db;
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
}
.image-url-load-button svg {
width: 20px;
height: 20px;
}
.image-url-load-button:hover:not(:disabled) {
background: #2980b9;
transform: translateY(-1px);
}
.image-url-load-button:disabled {
background: #9ca3af;
cursor: not-allowed;
transform: none;
}
.cropper-modal {
position: fixed;
top: 0;
@@ -476,7 +583,9 @@
.date-selector-display-date {
flex: 1;
padding: 0.75rem;
height: 2.75rem;
box-sizing: border-box;
padding: 0 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
@@ -485,6 +594,8 @@
transition: all 0.2s;
color: #1f2937;
user-select: none;
display: flex;
align-items: center;
}
.date-selector-display-date:hover {
@@ -540,7 +651,9 @@
.task-autocomplete-input {
width: 100%;
padding: 12px 36px 12px 14px;
height: 2.75rem;
box-sizing: border-box;
padding: 0 36px 0 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;

View File

@@ -35,6 +35,8 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
const [fetchingMetadata, setFetchingMetadata] = useState(false)
const [restoredFromSession, setRestoredFromSession] = useState(false) // Флаг восстановления из sessionStorage
const [loadedWishlistData, setLoadedWishlistData] = useState(null) // Данные желания для последующего маппинга условий
const [imageUrlInput, setImageUrlInput] = useState('') // Ссылка на картинку для загрузки по URL
const [loadingImageFromUrl, setLoadingImageFromUrl] = useState(false)
const fileInputRef = useRef(null)
// Загрузка задач, проектов и саджестов групп
@@ -237,6 +239,8 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedAreaPixels(null)
setImageUrlInput('')
setLoadingImageFromUrl(false)
setShowConditionForm(false)
setEditingConditionIndex(null)
setToastMessage(null)
@@ -300,6 +304,8 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
setImageUrl(null)
setImageFile(null)
setImageRemoved(false)
setImageUrlInput('')
setLoadingImageFromUrl(false)
setUnlockConditions([])
setGroupName('')
setError('')
@@ -434,6 +440,53 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
reader.readAsDataURL(file)
}
// Загрузка картинки по ссылке с последующим кропом
const loadImageFromUrl = async () => {
const url = imageUrlInput?.trim()
if (!url) {
setToastMessage({ text: 'Введите ссылку на картинку', type: 'error' })
return
}
try {
new URL(url)
} catch {
setToastMessage({ text: 'Некорректная ссылка на картинку', type: 'error' })
return
}
setLoadingImageFromUrl(true)
try {
const proxyUrl = `${API_URL}/proxy-image?url=${encodeURIComponent(url)}`
const imgResponse = await authFetch(proxyUrl)
if (!imgResponse.ok) {
const errData = await imgResponse.json().catch(() => ({}))
throw new Error(errData.message || errData.error || 'Не удалось загрузить картинку')
}
const blob = await imgResponse.blob()
if (blob.size > 5 * 1024 * 1024) {
setToastMessage({ text: 'Картинка слишком большая (максимум 5MB)', type: 'error' })
return
}
if (!blob.type.startsWith('image/')) {
setToastMessage({ text: 'По ссылке не изображение', type: 'error' })
return
}
const reader = new FileReader()
reader.onload = () => {
setImageUrl(reader.result)
setImageFile(blob)
setImageRemoved(false)
setImageUrlInput('')
setShowCropper(true)
}
reader.readAsDataURL(blob)
} catch (err) {
setToastMessage({ text: err.message || 'Ошибка при загрузке картинки по ссылке', type: 'error' })
} finally {
setLoadingImageFromUrl(false)
}
}
const onCropComplete = (croppedArea, croppedAreaPixels) => {
setCroppedAreaPixels(croppedAreaPixels)
}
@@ -788,14 +841,51 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b
</button>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageSelect}
className="form-input"
style={{ display: imageUrl ? 'none' : 'block' }}
/>
{!imageUrl && (
<>
<div className="image-url-row">
<span className="image-url-label">Файл:</span>
<label className="file-input-label">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageSelect}
className="file-input-hidden"
/>
<span className="file-input-button">Выбрать</span>
</label>
</div>
<div className="image-url-row">
<span className="image-url-label">Ссылка:</span>
<input
type="url"
value={imageUrlInput}
onChange={(e) => setImageUrlInput(e.target.value)}
placeholder="https://..."
className="form-input image-url-input"
disabled={loadingImageFromUrl}
/>
<button
type="button"
className="image-url-load-button"
onClick={loadImageFromUrl}
disabled={loadingImageFromUrl || !imageUrlInput.trim()}
title="Загрузить картинку по ссылке и обрезать"
>
{loadingImageFromUrl ? (
<div className="mini-spinner"></div>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
)}
</button>
</div>
</>
)}
</div>
{showCropper && (