Merge pull request #26 from SlimusMinus/fix_images
added many images in post
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
import {useCallback, useEffect, useState} from "react";
|
import {useCallback, useEffect, useState} from "react";
|
||||||
import PinIcon from "./icons/PinIcon";
|
|
||||||
import CloseIcon from "./icons/CloseIcon";
|
import CloseIcon from "./icons/CloseIcon";
|
||||||
import { EMPTY_FORM, CATEGORIES } from "../config/feedConstants";
|
import { EMPTY_FORM, CATEGORIES } from "../config/feedConstants";
|
||||||
import { usePostMutation } from "../hooks/usePostMutation";
|
import { usePostMutation } from "../hooks/usePostMutation";
|
||||||
@@ -7,7 +6,6 @@ import { useImageUpload, ACCEPT } from "../hooks/useImageUpload";
|
|||||||
import { validatePostForm } from "../utils/validatePostForm";
|
import { validatePostForm } from "../utils/validatePostForm";
|
||||||
import { formatPhone } from "../utils/formatPhone";
|
import { formatPhone } from "../utils/formatPhone";
|
||||||
import { useYandexMap } from "../hooks/useYandexMap";
|
import { useYandexMap } from "../hooks/useYandexMap";
|
||||||
import { API_URL } from "../config/authConstants";
|
|
||||||
|
|
||||||
const MAX_SIZE_MB = 5;
|
const MAX_SIZE_MB = 5;
|
||||||
|
|
||||||
@@ -40,21 +38,16 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
|||||||
// Ошибки валидации — ключ совпадает с именем поля
|
// Ошибки валидации — ключ совпадает с именем поля
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
// URL существующей картинки с сервера (только при редактировании)
|
|
||||||
const existingImageUrl = isEdit && initialData.images?.[0]
|
|
||||||
? `${API_URL}${initialData.images[0]}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Вся логика работы с фото вынесена в хук
|
|
||||||
const {
|
const {
|
||||||
imageFile,
|
images: uploadedImages,
|
||||||
displayImage,
|
|
||||||
imgError,
|
imgError,
|
||||||
compressing,
|
compressing,
|
||||||
fileInputRef,
|
fileInputRef,
|
||||||
handleFileChange,
|
handleFileChange,
|
||||||
removeImage,
|
removeImage,
|
||||||
} = useImageUpload(existingImageUrl);
|
getFilesForUpload,
|
||||||
|
getExistingUrlsToKeep,
|
||||||
|
} = useImageUpload(isEdit ? initialData.images : []);
|
||||||
|
|
||||||
// Единый хук для создания и обновления
|
// Единый хук для создания и обновления
|
||||||
const { save, loading: saving, error: apiError } = usePostMutation();
|
const { save, loading: saving, error: apiError } = usePostMutation();
|
||||||
@@ -106,7 +99,15 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
|||||||
data.append("category", form.category);
|
data.append("category", form.category);
|
||||||
data.append("phone", form.phone.trim());
|
data.append("phone", form.phone.trim());
|
||||||
if (form.date) data.append("date", form.date);
|
if (form.date) data.append("date", form.date);
|
||||||
if (imageFile) data.append("image", imageFile);
|
const files = getFilesForUpload();
|
||||||
|
files.forEach(f => data.append("images", f));
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
const existing = getExistingUrlsToKeep();
|
||||||
|
if (existing.length > 0) {
|
||||||
|
data.append("existingImages", JSON.stringify(existing));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (coords) {
|
if (coords) {
|
||||||
data.append("latitude", coords.latitude);
|
data.append("latitude", coords.latitude);
|
||||||
@@ -189,41 +190,50 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
|
|||||||
className="f-err-msg">{errors.description}</p>}
|
className="f-err-msg">{errors.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Фотография: превью + удаление или загрузчик */}
|
{/* Фотографии: сетка превью + кнопка добавления */}
|
||||||
<div className="f-m-group">
|
<div className="f-m-group">
|
||||||
<label className="f-m-label">Фотография</label>
|
<label className="f-m-label">Фотографии</label>
|
||||||
{displayImage ? (
|
|
||||||
<div className="f-img-preview-wrap">
|
{uploadedImages.length > 0 && (
|
||||||
<img src={displayImage} alt="preview"
|
<div className="f-img-grid">
|
||||||
className="f-img-preview"/>
|
{uploadedImages.map(img => (
|
||||||
<button
|
<div key={img.id} className="f-img-grid-item">
|
||||||
className="f-img-remove"
|
<img src={img.preview} alt="" className="f-img-grid-preview"/>
|
||||||
onClick={removeImage}
|
<button
|
||||||
type="button"
|
className="f-img-remove"
|
||||||
aria-label="Удалить фото"
|
onClick={() => removeImage(img.id)}
|
||||||
>
|
type="button"
|
||||||
<CloseIcon/>
|
aria-label="Удалить фото"
|
||||||
</button>
|
>
|
||||||
|
<CloseIcon/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<label className="f-img-upload-label"
|
|
||||||
aria-disabled={compressing}>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept={ACCEPT}
|
|
||||||
// На мобильных открываем камеру напрямую
|
|
||||||
capture={/Mobi|Android|iPhone/i.test(navigator.userAgent)
|
|
||||||
? "environment" : undefined}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
className="f-img-input"
|
|
||||||
disabled={compressing}
|
|
||||||
/>
|
|
||||||
{compressing ? <span>Обработка...</span> : <span>📷 Добавить фото</span>}
|
|
||||||
</label>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<label className="f-img-upload-label"
|
||||||
|
aria-disabled={compressing}>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPT}
|
||||||
|
multiple
|
||||||
|
capture={/Mobi|Android|iPhone/i.test(navigator.userAgent)
|
||||||
|
? "environment" : undefined}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="f-img-input"
|
||||||
|
disabled={compressing}
|
||||||
|
/>
|
||||||
|
{compressing ? (
|
||||||
|
<span>Обработка...</span>
|
||||||
|
) : (
|
||||||
|
<span>{uploadedImages.length > 0 ? "➕ Добавить ещё" : "📷 Добавить фото"}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
{imgError && <p className="f-err-msg">{imgError}</p>}
|
{imgError && <p className="f-err-msg">{imgError}</p>}
|
||||||
<p className="f-hint">JPEG / PNG / WebP · до {MAX_SIZE_MB} МБ</p>
|
<p className="f-hint">JPEG / PNG / WebP · до {MAX_SIZE_MB} МБ · можно несколько</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Категория */}
|
{/* Категория */}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Sidebar from "./Sidebar";
|
|||||||
import RightSidebar from "./RightSidebar";
|
import RightSidebar from "./RightSidebar";
|
||||||
import PostOnFeed from "./PostOnFeed";
|
import PostOnFeed from "./PostOnFeed";
|
||||||
import CreateUpdatePost from "./CreateUpdatePost";
|
import CreateUpdatePost from "./CreateUpdatePost";
|
||||||
|
import ImageViewer from "./ImageViewer";
|
||||||
import MenuIcon from "./icons/MenuIcon";
|
import MenuIcon from "./icons/MenuIcon";
|
||||||
import ProfileAvatar from "./profile/ProfileAvatar";
|
import ProfileAvatar from "./profile/ProfileAvatar";
|
||||||
import { ProfileModal } from "./profile";
|
import { ProfileModal } from "./profile";
|
||||||
@@ -44,6 +45,8 @@ export default function MainFeed() {
|
|||||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||||
// Открыто ли модальное окно правил площадки
|
// Открыто ли модальное окно правил площадки
|
||||||
const [rulesModalOpen, setRulesModalOpen] = useState(false);
|
const [rulesModalOpen, setRulesModalOpen] = useState(false);
|
||||||
|
// Просмотр изображений: null — закрыто, { images, index } — открыто
|
||||||
|
const [viewerData, setViewerData] = useState(null);
|
||||||
// Массив объявлений из API (основная лента)
|
// Массив объявлений из API (основная лента)
|
||||||
const [ads, setAds] = useState([]);
|
const [ads, setAds] = useState([]);
|
||||||
// Объявления текущего пользователя (загружаются отдельно по userId)
|
// Объявления текущего пользователя (загружаются отдельно по userId)
|
||||||
@@ -193,6 +196,12 @@ export default function MainFeed() {
|
|||||||
const handleNewAd = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]);
|
const handleNewAd = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]);
|
||||||
const handleEditSave = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]);
|
const handleEditSave = useCallback(() => { loadFeed(); loadMyPosts(); }, [loadFeed, loadMyPosts]);
|
||||||
|
|
||||||
|
const handleImageClick = useCallback((images, index) => {
|
||||||
|
if (images?.length) {
|
||||||
|
setViewerData({ images, index });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обработчик нажатия «Разместить объявление».
|
* Обработчик нажатия «Разместить объявление».
|
||||||
* Если пользователь не авторизован — открывает модалку входа.
|
* Если пользователь не авторизован — открывает модалку входа.
|
||||||
@@ -301,6 +310,7 @@ export default function MainFeed() {
|
|||||||
onDelete={activeNav === "Мои объявления" ? handleDelete : undefined}
|
onDelete={activeNav === "Мои объявления" ? handleDelete : undefined}
|
||||||
onMarkFound={activeNav === "Мои объявления" ? handleMarkFound : undefined}
|
onMarkFound={activeNav === "Мои объявления" ? handleMarkFound : undefined}
|
||||||
isOwner={activeNav === "Мои объявления"}
|
isOwner={activeNav === "Мои объявления"}
|
||||||
|
onImageClick={handleImageClick}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
@@ -411,6 +421,15 @@ export default function MainFeed() {
|
|||||||
{rulesModalOpen && (
|
{rulesModalOpen && (
|
||||||
<RulesModal onClose={() => setRulesModalOpen(false)} />
|
<RulesModal onClose={() => setRulesModalOpen(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Просмотр изображений */}
|
||||||
|
{viewerData && (
|
||||||
|
<ImageViewer
|
||||||
|
images={viewerData.images}
|
||||||
|
initialIndex={viewerData.index}
|
||||||
|
onClose={() => setViewerData(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useChats } from "../hooks/useChats";
|
|||||||
import { useAuthContext } from "../context/AuthContext";
|
import { useAuthContext } from "../context/AuthContext";
|
||||||
import { API_URL } from "../config/authConstants";
|
import { API_URL } from "../config/authConstants";
|
||||||
|
|
||||||
export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMarkFound, isOwner }) {
|
export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMarkFound, isOwner, onImageClick }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { createOrGetChat } = useChats();
|
const { createOrGetChat } = useChats();
|
||||||
const { isAuthenticated } = useAuthContext();
|
const { isAuthenticated } = useAuthContext();
|
||||||
@@ -55,7 +55,7 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
|
|||||||
return (
|
return (
|
||||||
<div className={`f-card${isClosed ? ' f-card-closed' : ''}${roleClass ? ' ' + roleClass : ''}`}>
|
<div className={`f-card${isClosed ? ' f-card-closed' : ''}${roleClass ? ' ' + roleClass : ''}`}>
|
||||||
<div className="f-card-inner">
|
<div className="f-card-inner">
|
||||||
<div className="f-card-img">
|
<div className="f-card-img" onClick={e => { e.stopPropagation(); if (onImageClick) onImageClick(ad.images, 0); }}>
|
||||||
{imageUrl
|
{imageUrl
|
||||||
? <img src={imageUrl} alt={ad.title} />
|
? <img src={imageUrl} alt={ad.title} />
|
||||||
: <span className="f-card-emoji">{ad.emoji || "📋"}</span>
|
: <span className="f-card-emoji">{ad.emoji || "📋"}</span>
|
||||||
|
|||||||
@@ -1,87 +1,106 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { compressImage } from "../utils/compressImage";
|
import { compressImage } from "../utils/compressImage";
|
||||||
|
import { API_URL } from "../config/authConstants";
|
||||||
|
|
||||||
const MAX_SIZE_MB = 5;
|
const MAX_SIZE_MB = 5;
|
||||||
export const ACCEPT = "image/jpeg,image/png,image/webp";
|
export const ACCEPT = "image/jpeg,image/png,image/webp";
|
||||||
|
|
||||||
/**
|
let idCounter = 0;
|
||||||
* Управляет выбором, сжатием и удалением фотографии в форме объявления.
|
const nextId = () => `img-${++idCounter}-${Date.now()}`;
|
||||||
*
|
|
||||||
* @param {string|null} existingUrl — URL существующей картинки с сервера
|
export function useImageUpload(existingUrls = []) {
|
||||||
* (передаётся при редактировании)
|
const [images, setImages] = useState([]);
|
||||||
* @returns {{
|
const [imgError, setImgError] = useState("");
|
||||||
* imageFile: File|null,
|
|
||||||
* displayImage: string|null,
|
|
||||||
* imgError: string,
|
|
||||||
* compressing: boolean,
|
|
||||||
* fileInputRef: React.RefObject,
|
|
||||||
* handleFileChange: function,
|
|
||||||
* removeImage: function,
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
export function useImageUpload(existingUrl = null) {
|
|
||||||
// Сжатый файл, готовый к отправке в FormData
|
|
||||||
const [imageFile, setImageFile] = useState(null);
|
|
||||||
// ObjectURL для предпросмотра нового файла
|
|
||||||
const [preview, setPreview] = useState(null);
|
|
||||||
// true — пользователь явно удалил фото, existingUrl больше не показываем
|
|
||||||
const [removed, setRemoved] = useState(false);
|
|
||||||
const [imgError, setImgError] = useState("");
|
|
||||||
// true пока идёт сжатие — нужен чтобы заблокировать сабмит
|
|
||||||
const [compressing, setCompressing] = useState(false);
|
const [compressing, setCompressing] = useState(false);
|
||||||
// ref на <input type="file"> — нужен для сброса значения после removeImage
|
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
// Что показывать в блоке превью:
|
const existingUrlList = Array.isArray(existingUrls) ? existingUrls : [];
|
||||||
// новый preview имеет приоритет, иначе existingUrl (если не удалён)
|
|
||||||
const displayImage = preview || (!removed ? existingUrl : null);
|
|
||||||
|
|
||||||
/** Проверяет файл, сжимает его и создаёт превью. */
|
useEffect(() => {
|
||||||
const handleFileChange = async (e) => {
|
if (existingUrlList.length > 0) {
|
||||||
const file = e.target.files?.[0];
|
setImages(existingUrlList.map((url, i) => ({
|
||||||
if (!file) return;
|
id: `existing-${i}`,
|
||||||
|
file: null,
|
||||||
|
preview: `${API_URL}${url}`,
|
||||||
|
isExisting: true,
|
||||||
|
serverUrl: url,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(async (e) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
if (!files.length) return;
|
||||||
setImgError("");
|
setImgError("");
|
||||||
|
|
||||||
if (!file.type.startsWith("image/")) {
|
for (const file of files) {
|
||||||
setImgError("Только изображения (JPEG, PNG, WebP)");
|
if (!file.type.startsWith("image/")) {
|
||||||
return;
|
setImgError("Только изображения (JPEG, PNG, WebP)");
|
||||||
}
|
return;
|
||||||
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
|
}
|
||||||
setImgError(`Файл слишком большой — максимум ${MAX_SIZE_MB} МБ`);
|
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
|
||||||
return;
|
setImgError(`Файл слишком большой — максимум ${MAX_SIZE_MB} МБ`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setCompressing(true);
|
setCompressing(true);
|
||||||
try {
|
try {
|
||||||
const compressed = await compressImage(file);
|
const compressedFiles = await Promise.all(files.map(f => compressImage(f)));
|
||||||
setImageFile(compressed);
|
const newImages = compressedFiles.map(file => ({
|
||||||
setPreview(URL.createObjectURL(compressed));
|
id: nextId(),
|
||||||
setRemoved(false);
|
file,
|
||||||
|
preview: URL.createObjectURL(file),
|
||||||
|
isExisting: false,
|
||||||
|
serverUrl: null,
|
||||||
|
}));
|
||||||
|
setImages(prev => [...prev, ...newImages]);
|
||||||
} catch {
|
} catch {
|
||||||
setImgError("Не удалось обработать изображение");
|
setImgError("Не удалось обработать изображение");
|
||||||
} finally {
|
} finally {
|
||||||
setCompressing(false);
|
setCompressing(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
/** Удаляет фото, освобождает ObjectURL и сбрасывает input. */
|
const removeImage = useCallback((id) => {
|
||||||
const removeImage = () => {
|
setImages(prev => {
|
||||||
setImageFile(null);
|
const img = prev.find(i => i.id === id);
|
||||||
if (preview) URL.revokeObjectURL(preview); // предотвращаем утечку памяти
|
if (img && !img.isExisting && img.preview) {
|
||||||
setPreview(null);
|
URL.revokeObjectURL(img.preview);
|
||||||
setRemoved(true);
|
}
|
||||||
setImgError("");
|
return prev.filter(i => i.id !== id);
|
||||||
// Сброс нужен, иначе повторный выбор того же файла не вызовет onChange
|
});
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
}, []);
|
||||||
};
|
|
||||||
|
const getFilesForUpload = useCallback(() => {
|
||||||
|
return images.filter(i => !i.isExisting).map(i => i.file);
|
||||||
|
}, [images]);
|
||||||
|
|
||||||
|
const getExistingUrlsToKeep = useCallback(() => {
|
||||||
|
return images.filter(i => i.isExisting).map(i => i.serverUrl);
|
||||||
|
}, [images]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
images.forEach(img => {
|
||||||
|
if (!img.isExisting && img.preview) {
|
||||||
|
URL.revokeObjectURL(img.preview);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
imageFile,
|
images,
|
||||||
displayImage,
|
|
||||||
imgError,
|
imgError,
|
||||||
compressing,
|
compressing,
|
||||||
fileInputRef,
|
fileInputRef,
|
||||||
handleFileChange,
|
handleFileChange,
|
||||||
removeImage,
|
removeImage,
|
||||||
|
getFilesForUpload,
|
||||||
|
getExistingUrlsToKeep,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,17 +76,113 @@
|
|||||||
.f-img-upload-label:hover { border-color: var(--teal); background: rgba(var(--teal-rgb), .05); color: var(--teal); }
|
.f-img-upload-label:hover { border-color: var(--teal); background: rgba(var(--teal-rgb), .05); color: var(--teal); }
|
||||||
.f-img-input { display: none; }
|
.f-img-input { display: none; }
|
||||||
|
|
||||||
/* Image Preview */
|
/* Multi-image Grid */
|
||||||
.f-img-preview-wrap { position: relative; display: block; width: 100%; }
|
.f-img-grid {
|
||||||
.f-img-preview { width: 100%; height: 200px; object-fit: cover; object-position: center; border-radius: 12px; display: block; }
|
display: grid;
|
||||||
.f-img-remove {
|
grid-template-columns: repeat(3, 1fr);
|
||||||
position: absolute; top: 8px; right: 8px;
|
gap: 8px;
|
||||||
background: rgba(0,0,0,0.55); border: none; border-radius: 50%;
|
margin-bottom: 10px;
|
||||||
width: 28px; height: 28px;
|
}
|
||||||
display: flex; align-items: center; justify-content: center;
|
.f-img-grid-item {
|
||||||
cursor: pointer; color: #fff; padding: 0; transition: background .2s;
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.f-img-grid-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.f-img-grid-item .f-img-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background: rgba(0,0,0,0.55);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0;
|
||||||
|
transition: background .2s;
|
||||||
|
}
|
||||||
|
.f-img-grid-item .f-img-remove:hover { background: rgba(0,0,0,0.8); }
|
||||||
|
.f-img-grid-item .f-img-remove svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Image Viewer Modal ── */
|
||||||
|
.f-viewer-bg {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,.85);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: mfadeIn .2s ease;
|
||||||
|
}
|
||||||
|
.f-viewer {
|
||||||
|
position: relative;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.f-viewer-img {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: contain;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 40px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
.f-viewer-close {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,.12);
|
||||||
|
border: 1px solid rgba(255,255,255,.2);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 210;
|
||||||
|
transition: background .2s;
|
||||||
|
}
|
||||||
|
.f-viewer-close:hover {
|
||||||
|
background: rgba(255,255,255,.25);
|
||||||
|
}
|
||||||
|
.f-viewer-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.f-viewer-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,.3);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: background .2s;
|
||||||
|
}
|
||||||
|
.f-viewer-dot.active {
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
.f-img-remove:hover { background: rgba(0,0,0,0.8); }
|
|
||||||
|
|
||||||
/* Hints & Errors */
|
/* Hints & Errors */
|
||||||
.f-hint { font-size: .72rem; color: var(--muted); margin-top: 4px; }
|
.f-hint { font-size: .72rem; color: var(--muted); margin-top: 4px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user