Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ export function EnumField({
virtualMcpId={sandbox?.virtualMcpId}
/>
<Select
value={selectValue}
// "" (no selection, the placeholder) rather than undefined keeps the
// Select controlled when a value arrives later.
value={selectValue ?? ""}
onValueChange={(v) => onChange(selectValueToFormValue(v, options))}
>
<SelectTrigger id={path} className="h-10 w-full min-w-0">
Expand Down
66 changes: 52 additions & 14 deletions apps/web/src/components/sections-editor/fields/file-field.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { File02, Film01, Trash01, Upload01 } from "@untitledui/icons";
import { toast } from "sonner";
import { Button } from "@decocms/ui/components/button.tsx";
Expand All @@ -12,6 +12,10 @@ import {
import { matchSiteSlugConfig } from "@/components/file-picker/match-site-slug-config";
import { useFileConfigsQuery } from "@/hooks/use-file-configs";
import { useFilePickerUpload } from "@/hooks/use-file-picker";
import {
useServeAssetSrc,
useServeAssetUpload,
} from "./use-serve-asset-upload";
import { ClickToReplaceOverlay } from "./click-to-replace-overlay";
import { extractUrl } from "./extract-url";
import { FieldLabel } from "./field-label";
Expand Down Expand Up @@ -45,8 +49,16 @@ export function FileField({
const fileName = strValue ? basename(strValue) : "";
const ext = fileName ? extension(fileName) : "";

const configsQuery = useFileConfigsQuery();
// A connected `deco serve` writes uploads into the repository instead, so
// Studio's file storage (and its bucket picker) is left out.
const serveUpload = useServeAssetUpload(sandbox);
const configsQuery = useFileConfigsQuery({ enabled: !serveUpload });
const upload = useFilePickerUpload();
const fileInputRef = useRef<HTMLInputElement>(null);
/** Choose a file: the system file dialog for `deco serve`, else the picker. */
const browse = () =>
serveUpload ? fileInputRef.current?.click() : setPickerOpen(true);
const previewSrc = useServeAssetSrc(sandbox, strValue);
const lockedConfig = matchSiteSlugConfig(
configsQuery.data?.configs ?? [],
sandbox?.siteSlug,
Expand All @@ -64,6 +76,19 @@ export function FileField({
}
}

if (serveUpload) {
try {
onChange(await serveUpload(list[0]!));
} catch (err) {
toast.error(
err instanceof Error
? err.message
: t("sectionsEditor.fileField.uploadFailed"),
);
}
return;
}

const targetConfigId = resolveTargetConfigId(
configsQuery.data?.configs ?? [],
lockedConfig?.id,
Expand Down Expand Up @@ -146,13 +171,13 @@ export function FileField({
<>
<button
type="button"
onClick={() => setPickerOpen(true)}
onClick={browse}
aria-label={t("sectionsEditor.fileField.replaceVideoLabel")}
className="relative block h-40 w-full cursor-pointer overflow-hidden bg-black"
>
<video
key={strValue}
src={strValue}
src={previewSrc}
preload="metadata"
className="h-full w-full object-contain"
/>
Expand All @@ -169,7 +194,7 @@ export function FileField({
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
onClick={browse}
aria-label={t("sectionsEditor.fileField.replaceFileLabel")}
className="flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left hover:bg-muted/60"
>
Expand All @@ -188,7 +213,7 @@ export function FileField({
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
onClick={browse}
className="flex w-full flex-col items-center justify-center gap-2 py-8 text-sm text-muted-foreground hover:bg-muted/60 hover:text-foreground"
>
{isVideo ? <Film01 size={20} /> : <File02 size={20} />}
Expand Down Expand Up @@ -228,7 +253,7 @@ export function FileField({
type="button"
variant="outline"
size="sm"
onClick={() => setPickerOpen(true)}
onClick={browse}
className="h-9 shrink-0"
>
<Upload01 size={14} />
Expand Down Expand Up @@ -256,13 +281,26 @@ export function FileField({
)}
</div>

<FilePickerDialog
open={pickerOpen}
onOpenChange={setPickerOpen}
mode="any"
onSelect={(url) => onChange(url)}
lockedConfigId={lockedConfig?.id ?? null}
/>
{serveUpload ? (
<input
ref={fileInputRef}
type="file"
accept={isVideo ? "video/*" : undefined}
className="hidden"
onChange={(e) => {
void handleFiles(e.target.files);
e.target.value = "";
}}
/>
) : (
<FilePickerDialog
open={pickerOpen}
onOpenChange={setPickerOpen}
mode="any"
onSelect={(url) => onChange(url)}
lockedConfigId={lockedConfig?.id ?? null}
/>
)}
</div>
);
}
87 changes: 68 additions & 19 deletions apps/web/src/components/sections-editor/fields/image-field.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { Image01, Trash01, Upload01 } from "@untitledui/icons";
import { Button } from "@decocms/ui/components/button.tsx";
import { Input } from "@decocms/ui/components/input.tsx";
Expand All @@ -12,7 +12,14 @@ import type { FieldProps } from "./field-props";
import { basename, extension } from "./media-filename";
import { MediaTransformControls } from "./media-transform-controls";
import { isSafeImageUrl, safeImageSrc } from "./safe-image-url";
import { useImageUpload } from "./use-image-upload";
import { ACCEPTED_IMAGE_TYPES, useImageUpload } from "./use-image-upload";
import {
useServeAssetSrc,
useServeAssetUpload,
} from "./use-serve-asset-upload";

/** How many times a thumbnail served by the site's dev app is retried. */
const SERVED_SRC_RETRIES = 3;

export function ImageField({
schema,
Expand All @@ -30,7 +37,18 @@ export function ImageField({
const [imageErrored, setImageErrored] = useState(false);
// An author types this field, so the value is untrusted until it has been
// through `safeImageSrc`; an unsafe scheme reduces to "".
const src = safeImageSrc(strValue);
// On a connected `deco serve` (v8), `/assets/*` loads from the site's dev
// app; everywhere else this is `strValue` unchanged.
const servedSrc = useServeAssetSrc(sandbox, strValue);
// A file just uploaded to `deco serve` can 404 for a moment, until the dev
// app's file watcher sees it: a dev-app thumbnail is retried a few times
// before it shows as unavailable.
const [loadRetry, setLoadRetry] = useState(0);
const src = safeImageSrc(
loadRetry > 0
? `${servedSrc}${servedSrc.includes("?") ? "&" : "?"}retry=${loadRetry}`
: servedSrc,
);
const unsafe = !!strValue && !src;
const fileName = strValue ? basename(strValue) : "";
const ext = fileName ? extension(fileName) : "";
Expand All @@ -46,6 +64,7 @@ export function ImageField({
function setValue(next: string) {
setImageLoaded(false);
setImageErrored(false);
setLoadRetry(0);
onChange(next);
}

Expand Down Expand Up @@ -87,11 +106,19 @@ export function ImageField({
if (urlDraft.trim() && !isSafeImageUrl(urlDraft)) setUrlDraft(strValue);
}

const { isDragging, isPending, lockedConfigId, dropProps } = useImageUpload({
siteSlug: sandbox?.siteSlug,
onUploaded: setValue,
onNeedsPicker: () => setPickerOpen(true),
});
const serveUpload = useServeAssetUpload(sandbox);
const fileInputRef = useRef<HTMLInputElement>(null);
/** Choose a file: the system file dialog for `deco serve`, else the picker. */
const browse = () =>
serveUpload ? fileInputRef.current?.click() : setPickerOpen(true);

const { isDragging, isPending, lockedConfigId, dropProps, handleFiles } =
useImageUpload({
siteSlug: sandbox?.siteSlug,
onUploaded: setValue,
onNeedsPicker: () => setPickerOpen(true),
serveUpload,
});

return (
// grid-cols-[minmax(0,1fr)] forces every child to be at most 100% of
Expand Down Expand Up @@ -124,7 +151,7 @@ export function ImageField({
<>
<button
type="button"
onClick={() => setPickerOpen(true)}
onClick={browse}
aria-label={t("sectionsEditor.imageField.replaceImage")}
className={cn(
"relative block w-full cursor-pointer bg-[image:linear-gradient(45deg,rgba(0,0,0,0.04)_25%,transparent_25%,transparent_75%,rgba(0,0,0,0.04)_75%),linear-gradient(45deg,rgba(0,0,0,0.04)_25%,transparent_25%,transparent_75%,rgba(0,0,0,0.04)_75%)] bg-[position:0_0,8px_8px] [background-size:16px_16px]",
Expand All @@ -144,7 +171,16 @@ export function ImageField({
!imageLoaded && "opacity-0",
)}
onLoad={() => setImageLoaded(true)}
onError={() => setImageErrored(true)}
onError={() => {
if (
servedSrc !== strValue &&
loadRetry < SERVED_SRC_RETRIES
) {
setTimeout(() => setLoadRetry((n) => n + 1), 600);
} else {
setImageErrored(true);
}
}}
/>
)}
{(imageErrored || unsafe) && (
Expand Down Expand Up @@ -176,7 +212,7 @@ export function ImageField({
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
onClick={browse}
className={cn(
"flex w-full flex-col items-center justify-center gap-2 text-sm text-muted-foreground hover:bg-muted/60 hover:text-foreground",
compact ? "h-28" : "h-40",
Expand Down Expand Up @@ -223,7 +259,7 @@ export function ImageField({
type="button"
variant="outline"
size="sm"
onClick={() => setPickerOpen(true)}
onClick={browse}
className="h-9 shrink-0"
>
<Upload01 size={14} />
Expand All @@ -247,13 +283,26 @@ export function ImageField({
)}
</div>

<FilePickerDialog
open={pickerOpen}
onOpenChange={setPickerOpen}
mode="image"
onSelect={(url) => setValue(url)}
lockedConfigId={lockedConfigId}
/>
{serveUpload ? (
<input
ref={fileInputRef}
type="file"
accept={[...ACCEPTED_IMAGE_TYPES].join(",")}
className="hidden"
onChange={(e) => {
void handleFiles(e.target.files);
e.target.value = "";
}}
/>
) : (
<FilePickerDialog
open={pickerOpen}
onOpenChange={setPickerOpen}
mode="image"
onSelect={(url) => setValue(url)}
lockedConfigId={lockedConfigId}
/>
)}
</div>
);
}
Loading
Loading