UI - ЛАУНЧЕР: LIVE PREVIEW МЕДИА СЕРВИСОВ

This commit is contained in:
DCCONSTRUCTIONS 2026-05-08 19:30:50 +03:00
parent a53f286860
commit 2129ffe336
2 changed files with 69 additions and 10 deletions

View File

@ -2952,9 +2952,10 @@ code {
.service-media-control { .service-media-control {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center; align-items: center;
min-height: 3.35rem; min-height: 3.35rem;
gap: 0.22rem;
overflow: hidden; overflow: hidden;
border-radius: var(--launcher-radius-circle); border-radius: var(--launcher-radius-circle);
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
@ -3018,6 +3019,34 @@ code {
gap: 0.12rem; gap: 0.12rem;
} }
.service-media-preview {
position: relative;
display: grid;
width: 2.78rem;
min-width: 2.78rem;
height: 2.78rem;
min-height: 2.78rem;
aspect-ratio: 1;
place-items: center;
overflow: hidden;
border-radius: var(--launcher-radius-circle);
background: rgba(255, 255, 255, 0.055);
color: rgba(255, 255, 255, 0.62);
}
.service-media-preview img,
.service-media-preview video {
position: absolute;
inset: 0;
display: block;
width: 100% !important;
max-width: none !important;
height: 100% !important;
max-height: none !important;
border-radius: inherit;
object-fit: cover;
}
.service-media-source-button { .service-media-source-button {
display: grid; display: grid;
width: 2.78rem; width: 2.78rem;

View File

@ -1487,20 +1487,42 @@ function ServiceContentModal({
onDelete: () => void; onDelete: () => void;
}) { }) {
const [draft, setDraft] = useState<Service>(service); const [draft, setDraft] = useState<Service>(service);
const [mediaPreviewUrls, setMediaPreviewUrls] = useState<{ cover: string | null; ambient: string | null }>({
cover: service.coverImageUrl ?? null,
ambient: service.ambientVideoUrl ?? null,
});
const [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | null>(null); const [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | null>(null);
const [storageError, setStorageError] = useState<string | null>(null); const [storageError, setStorageError] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
useEffect(() => { useEffect(() => {
setDraft(service); setDraft(service);
setMediaPreviewUrls({
cover: service.coverImageUrl ?? null,
ambient: service.ambientVideoUrl ?? null,
});
setStorageError(null); setStorageError(null);
setUploadingSlot(null); setUploadingSlot(null);
}, [service]); }, [service]);
useEffect(() => {
return () => {
Object.values(mediaPreviewUrls).forEach((previewUrl) => {
if (previewUrl?.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
}
});
};
}, [mediaPreviewUrls]);
function update<K extends keyof Service>(key: K, value: Service[K]) { function update<K extends keyof Service>(key: K, value: Service[K]) {
setDraft((current) => ({ ...current, [key]: value })); setDraft((current) => ({ ...current, [key]: value }));
} }
function updateMediaPreview(slot: "cover" | "ambient", previewUrl: string | null) {
setMediaPreviewUrls((current) => ({ ...current, [slot]: previewUrl }));
}
async function handleCoverUpload(file?: File) { async function handleCoverUpload(file?: File) {
if (!file) return; if (!file) return;
await uploadServiceMedia(file, "cover"); await uploadServiceMedia(file, "cover");
@ -1512,6 +1534,8 @@ function ServiceContentModal({
} }
async function uploadServiceMedia(file: File, slot: "cover" | "ambient") { async function uploadServiceMedia(file: File, slot: "cover" | "ambient") {
const localPreviewUrl = URL.createObjectURL(file);
updateMediaPreview(slot, localPreviewUrl);
setStorageError(null); setStorageError(null);
setUploadingSlot(slot); setUploadingSlot(slot);
@ -1532,6 +1556,7 @@ function ServiceContentModal({
} }
} catch (error) { } catch (error) {
setStorageError(error instanceof Error ? error.message : "Не удалось сохранить файл в storage"); setStorageError(error instanceof Error ? error.message : "Не удалось сохранить файл в storage");
updateMediaPreview(slot, slot === "cover" ? (draft.coverImageUrl ?? null) : (draft.ambientVideoUrl ?? null));
} finally { } finally {
setUploadingSlot(null); setUploadingSlot(null);
} }
@ -1588,12 +1613,15 @@ function ServiceContentModal({
value={draft.coverImageUrl ?? ""} value={draft.coverImageUrl ?? ""}
fileName={draft.coverMediaFileName ?? null} fileName={draft.coverMediaFileName ?? null}
isUploading={uploadingSlot === "cover"} isUploading={uploadingSlot === "cover"}
previewSrc={mediaPreviewUrls.cover}
previewKind={draft.coverMediaKind}
onSourceChange={(source) => update("coverMediaSource", source)} onSourceChange={(source) => update("coverMediaSource", source)}
onUrlChange={(value) => { onUrlChange={(value) => {
update("coverImageUrl", value || null); update("coverImageUrl", value || null);
update("coverMediaSource", "url"); update("coverMediaSource", "url");
update("coverMediaKind", mediaKindFromUrl(value)); update("coverMediaKind", mediaKindFromUrl(value));
update("coverMediaFileName", null); update("coverMediaFileName", null);
updateMediaPreview("cover", value || null);
}} }}
onFileChange={handleCoverUpload} onFileChange={handleCoverUpload}
/> />
@ -1605,24 +1633,19 @@ function ServiceContentModal({
value={draft.ambientVideoUrl ?? ""} value={draft.ambientVideoUrl ?? ""}
fileName={draft.ambientMediaFileName ?? null} fileName={draft.ambientMediaFileName ?? null}
isUploading={uploadingSlot === "ambient"} isUploading={uploadingSlot === "ambient"}
previewSrc={mediaPreviewUrls.ambient}
previewKind={draft.ambientMediaKind}
onSourceChange={(source) => update("ambientMediaSource", source)} onSourceChange={(source) => update("ambientMediaSource", source)}
onUrlChange={(value) => { onUrlChange={(value) => {
update("ambientVideoUrl", value || null); update("ambientVideoUrl", value || null);
update("ambientMediaSource", "url"); update("ambientMediaSource", "url");
update("ambientMediaKind", mediaKindFromUrl(value)); update("ambientMediaKind", mediaKindFromUrl(value));
update("ambientMediaFileName", null); update("ambientMediaFileName", null);
updateMediaPreview("ambient", value || null);
}} }}
onFileChange={handleAmbientUpload} onFileChange={handleAmbientUpload}
/> />
<div className="service-content-preview service-content-preview--image">
{draft.coverImageUrl ? <MediaPreview src={draft.coverImageUrl} kind={draft.coverMediaKind} /> : <ImageIcon size={30} />}
</div>
<div className="service-content-preview service-content-preview--video">
{draft.ambientVideoUrl ? <MediaPreview src={draft.ambientVideoUrl} kind={draft.ambientMediaKind} /> : <Video size={30} />}
</div>
{storageError ? <p className="service-content-storage-error">{storageError}</p> : null} {storageError ? <p className="service-content-storage-error">{storageError}</p> : null}
</div> </div>
@ -2257,6 +2280,8 @@ function MediaSourceField({
value, value,
fileName, fileName,
isUploading = false, isUploading = false,
previewSrc,
previewKind,
onSourceChange, onSourceChange,
onUrlChange, onUrlChange,
onFileChange, onFileChange,
@ -2267,6 +2292,8 @@ function MediaSourceField({
value: string; value: string;
fileName?: string | null; fileName?: string | null;
isUploading?: boolean; isUploading?: boolean;
previewSrc?: string | null;
previewKind?: MediaKind | null;
onSourceChange: (source: ServiceMediaSource) => void; onSourceChange: (source: ServiceMediaSource) => void;
onUrlChange: (value: string) => void; onUrlChange: (value: string) => void;
onFileChange: (file?: File) => void | Promise<void>; onFileChange: (file?: File) => void | Promise<void>;
@ -2276,7 +2303,7 @@ function MediaSourceField({
const fileTitle = isUploading ? undefined : (fileName ?? "Файл не выбран"); const fileTitle = isUploading ? undefined : (fileName ?? "Файл не выбран");
return ( return (
<div className="service-content-field service-media-field"> <div className="service-content-field service-content-field--wide service-media-field">
<span> <span>
{icon} {label} {icon} {label}
</span> </span>
@ -2315,6 +2342,9 @@ function MediaSourceField({
<Globe2 size={15} /> <Globe2 size={15} />
</button> </button>
</div> </div>
<div className="service-media-preview" aria-hidden="true">
{previewSrc ? <MediaPreview src={previewSrc} kind={previewKind} /> : icon}
</div>
</div> </div>
</div> </div>
); );