Skip to content
Merged
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
10 changes: 7 additions & 3 deletions apps/web/features/dashboard/sites/site-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getTeamSiteEditorPath } from "@/features/dashboard/routes";
import { getSiteOpenUrl } from "@/features/published-sites/urls";
import { api } from "@baseblocks/backend";
import type { Id } from "@baseblocks/backend";
import { managedFilePath } from "@baseblocks/domain";
import {
AlertDialog,
AlertDialogAction,
Expand Down Expand Up @@ -41,7 +42,7 @@ interface SiteCardProps {
_id: string;
name: string;
slug: string;
logoUrl?: string;
logoFileId?: string;
liveReleaseId?: string;
team?: {
_id: string;
Expand All @@ -64,6 +65,9 @@ export function SiteCard({ canManageSites, site, teamSlug }: SiteCardProps) {
const publishedSiteHref = getSiteOpenUrl(effectiveTeamSlug, site.slug);
const isPublished = Boolean(site.liveReleaseId);
const statusLabel = isPublished ? t("sites.published") : t("sites.draft");
const logoUrl = site.logoFileId
? managedFilePath(site.logoFileId)
: undefined;

const handleDelete = async () => {
setIsDeleting(true);
Expand All @@ -88,9 +92,9 @@ export function SiteCard({ canManageSites, site, teamSlug }: SiteCardProps) {
/>

<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-sm font-medium text-muted-foreground">
{site.logoUrl ? (
{logoUrl ? (
<Image
src={site.logoUrl}
src={logoUrl}
alt=""
aria-hidden="true"
className="size-10 object-cover"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { toast } from "sonner";
export type SiteManagementTarget = {
_id: string;
name: string;
logoUrl?: string;
logoFileId?: string;
};

Expand Down
4 changes: 2 additions & 2 deletions apps/web/features/dashboard/use-site-navigation.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { api, type Id } from "@baseblocks/backend";
import type { PageListItem } from "@baseblocks/domain";
import { managedFilePath, type PageListItem } from "@baseblocks/domain";
import { useQueries, useQuery, type RequestForQueries } from "convex/react";
import { useMemo } from "react";

Expand Down Expand Up @@ -49,7 +49,7 @@ export function useSiteNavigation(
navigation.push({
_id: site._id,
name: site.name,
logoUrl: site.logoUrl,
logoUrl: site.logoFileId ? managedFilePath(site.logoFileId) : undefined,
logoFileId: site.logoFileId,
defaultPageId: site.defaultPageId,
liveReleaseId: site.liveReleaseId,
Expand Down
37 changes: 23 additions & 14 deletions apps/web/features/editor/settings/favicon-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,22 @@

import { useImageUpload } from "@/lib/files/use-image-upload";
import type { Id } from "@baseblocks/backend";
import { managedFilePath } from "@baseblocks/domain";
import { useState } from "react";
import { toast } from "sonner";
import { ImageAssetDropZone } from "./image-asset-dropzone";

export function FaviconSettings({
favicon,
faviconFileId,
onChange,
siteId,
}: {
favicon?: string;
onChange: (favicon?: string) => Promise<void>;
faviconFileId?: Id<"files">;
onChange: (faviconFileId?: Id<"files">) => Promise<void>;
siteId: Id<"sites">;
}) {
const { uploadImage, uploadState } = useImageUpload();
const [isSaving, setIsSaving] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);

const upload = async (file?: File) => {
Expand All @@ -25,16 +27,24 @@ export function FaviconSettings({
return;
}

const result = await uploadImage(file, siteId).catch(() => null);
if (!result) {
if (isSaving) return;
setIsSaving(true);
try {
const result = await uploadImage(file, siteId);
if (result) {
await onChange(result.fileId);
toast.success("Favicon updated");
} else {
toast.error(
uploadState.error ?? "Unable to save the favicon. Try again.",
);
}
} catch {
toast.error(
uploadState.error ?? "Unable to upload the favicon. Try again.",
uploadState.error ?? "Unable to save the favicon. Try again.",
);
return;
}

await onChange(result.url);
toast.success("Favicon updated");
setIsSaving(false);
};

const remove = async () => {
Expand All @@ -45,20 +55,19 @@ export function FaviconSettings({
toast.success("Favicon removed");
} catch {
toast.error("Unable to remove the favicon. Try again.");
} finally {
setIsRemoving(false);
}
setIsRemoving(false);
};

return (
<ImageAssetDropZone
alt="Favicon"
isRemoving={isRemoving}
isUploading={uploadState.isUploading}
isUploading={uploadState.isUploading || isSaving}
onFileAccepted={(file) => void upload(file)}
onRemove={() => void remove()}
progress={uploadState.progress?.percentage}
src={favicon}
src={faviconFileId ? managedFilePath(faviconFileId) : undefined}
/>
);
}
47 changes: 23 additions & 24 deletions apps/web/features/editor/settings/site-brand-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useImageUpload } from "@/lib/files/use-image-upload";
import { api } from "@baseblocks/backend";
import type { Doc } from "@baseblocks/backend";
import { managedFilePath } from "@baseblocks/domain";
import { Button } from "@baseblocks/ui/button";
import { Input } from "@baseblocks/ui/input";
import { Label } from "@baseblocks/ui/label";
Expand All @@ -14,16 +15,13 @@ import { FaviconSettings } from "./favicon-settings";
import { ImageAssetDropZone } from "./image-asset-dropzone";
import { SiteSettingsSectionTitle } from "./site-settings-section-title";

export function SiteBrandSettings({
site,
}: {
site: Doc<"sites"> & { logoUrl?: string };
}) {
export function SiteBrandSettings({ site }: { site: Doc<"sites"> }) {
const siteId = site._id;
const updateSite = useMutation(api.sites.update);
const { uploadImage, uploadState } = useImageUpload();
const [name, setName] = useState(site.name);
const [isSavingName, setIsSavingName] = useState(false);
const [isSavingLogo, setIsSavingLogo] = useState(false);
const [isRemovingLogo, setIsRemovingLogo] = useState(false);

useEffect(() => setName(site.name), [site.name]);
Expand All @@ -38,9 +36,8 @@ export function SiteBrandSettings({
toast.success("Site name updated");
} catch {
toast.error("Unable to update the site name. Try again.");
} finally {
setIsSavingName(false);
}
setIsSavingName(false);
};

const uploadLogo = async (file?: File) => {
Expand All @@ -53,17 +50,20 @@ export function SiteBrandSettings({
toast.error("Select an image smaller than 5 MB.");
return;
}
const result = await uploadImage(file, siteId);
if (!result) {
toast.error(uploadState.error ?? "Unable to upload the logo. Try again.");
return;
}
if (isSavingLogo) return;
setIsSavingLogo(true);
try {
await updateSite({ siteId, logoFileId: result.fileId });
toast.success("Logo uploaded");
const result = await uploadImage(file, siteId);
if (result) {
await updateSite({ siteId, logoFileId: result.fileId });
toast.success("Logo uploaded");
} else {
toast.error(uploadState.error ?? "Unable to save the logo. Try again.");
}
} catch {
toast.error("Unable to save the logo. Try again.");
toast.error(uploadState.error ?? "Unable to save the logo. Try again.");
}
setIsSavingLogo(false);
};

const removeLogo = async () => {
Expand All @@ -74,9 +74,8 @@ export function SiteBrandSettings({
toast.success("Logo removed");
} catch {
toast.error("Unable to remove the logo. Try again.");
} finally {
setIsRemovingLogo(false);
}
setIsRemovingLogo(false);
};

return (
Expand All @@ -90,7 +89,7 @@ export function SiteBrandSettings({
size="compact"
>
{isSavingName ? <Spinner className="size-3.5" /> : null}
Save changes
Save name
</Button>
</div>
<div className="space-y-2">
Expand All @@ -113,22 +112,22 @@ export function SiteBrandSettings({
<AssetRow label="Logo">
<ImageAssetDropZone
alt="Site logo"
isUploading={uploadState.isUploading}
isUploading={uploadState.isUploading || isSavingLogo}
isRemoving={isRemovingLogo}
onFileAccepted={(file) => void uploadLogo(file)}
onRemove={() => void removeLogo()}
progress={uploadState.progress?.percentage}
src={site.logoUrl}
src={site.logoFileId ? managedFilePath(site.logoFileId) : undefined}
/>
</AssetRow>
<AssetRow label="Favicon">
<FaviconSettings
favicon={site.settings.favicon}
faviconFileId={site.faviconFileId}
siteId={siteId}
onChange={async (favicon) => {
onChange={async (faviconFileId) => {
await updateSite(
favicon
? { siteId, settings: { favicon } }
faviconFileId
? { siteId, faviconFileId }
: { siteId, clearFavicon: true },
);
}}
Expand Down
14 changes: 14 additions & 0 deletions apps/web/features/openeditor/custom-block-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,17 @@ describe("custom block URL resolution", () => {
expect(host.resolveUrl("//other.example/file", "asset")).toBeNull();
});
});

test("custom block host forwards pending asset disposal", async () => {
const discarded: string[] = [];
const host = createBaseBlocksCustomBlockHost(
new Set(),
undefined,
async (id) => {
discarded.push(id);
},
);

await host.assets.discard?.("pending-asset");
expect(discarded).toEqual(["pending-asset"]);
});
2 changes: 2 additions & 0 deletions apps/web/features/openeditor/custom-block-host.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const safeUrl = (value: string, _context: "navigation" | "asset") => {
export const createBaseBlocksCustomBlockHost = (
authorizedAssetIds: Pick<ReadonlySet<string>, "has">,
pickAsset?: () => Promise<{ id: string; kind: "raster"; alt: string } | null>,
discardAsset?: (id: string) => Promise<void>,
) => ({
resolveUrl: safeUrl,
links: {
Expand All @@ -27,6 +28,7 @@ export const createBaseBlocksCustomBlockHost = (
},
assets: {
pick: pickAsset,
discard: discardAsset,
resolve: async (id: string) =>
authorizedAssetIds.has(id) && /^[A-Za-z0-9_-]+$/.test(id)
? { src: `/api/files/${encodeURIComponent(id)}`, alt: "" }
Expand Down
2 changes: 1 addition & 1 deletion apps/web/features/openeditor/custom-block-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ type NestedRuntimes = {
pageRuntime?: OpenEditorPageRuntime;
};
export const createBaseBlocksCustomBlockViewerConfiguration = (
authorizedAssetIds: ReadonlySet<string>,
authorizedAssetIds: Pick<ReadonlySet<string>, "has">,
runtimes: NestedRuntimes = {},
) => {
const DocumentViewer = ({
Expand Down
63 changes: 62 additions & 1 deletion apps/web/features/openeditor/custom-blocks.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, test } from "bun:test";
import type { OpenEditorDocument } from "@openeditor/core";
import { extractBaseBlocksCustomBlockAssetIds } from "./custom-blocks";
import {
BaseBlocksCustomBlockAssetAuthorization,
extractBaseBlocksCustomBlockAssetIds,
} from "./custom-blocks";
import { createBaseBlocksCustomBlockHost } from "./custom-block-host";

const quickLinksWithAsset = (assetId: string) => ({
type: "customBlock",
Expand Down Expand Up @@ -54,4 +58,61 @@ describe("custom-block asset authorization", () => {
new Set(["top_asset", "nested_asset"]),
);
});

test("keeps a newly uploaded asset resolvable before the document saves", async () => {
const document = {
type: "doc",
version: 1,
content: [],
} as OpenEditorDocument;
const authorization = new BaseBlocksCustomBlockAssetAuthorization(document);
const uploaded = authorization.authorize({
id: "new_image",
kind: "raster" as const,
alt: "Preview",
});

authorization.updateDocument(document);
const host = createBaseBlocksCustomBlockHost(authorization);

expect(uploaded).not.toBeNull();
expect(await host.assets.resolve(uploaded!.id)).toEqual({
src: "/api/files/new_image",
alt: "",
});
});

test("stops authorizing a saved asset after the document removes it", () => {
const authorization = new BaseBlocksCustomBlockAssetAuthorization({
type: "doc",
version: 1,
content: [quickLinksWithAsset("removed_image")],
} as OpenEditorDocument);

expect(authorization.has("removed_image")).toBe(true);
authorization.updateDocument({
type: "doc",
version: 1,
content: [],
} as OpenEditorDocument);

expect(authorization.has("removed_image")).toBe(false);
});

test("discards only assets that have not entered the document", () => {
const authorization = new BaseBlocksCustomBlockAssetAuthorization({
type: "doc",
version: 1,
content: [quickLinksWithAsset("saved_image")],
} as OpenEditorDocument);
authorization.authorize({
id: "pending_image",
kind: "raster" as const,
alt: "",
});

expect(authorization.discard("saved_image")).toBe(false);
expect(authorization.discard("pending_image")).toBe(true);
expect(authorization.has("pending_image")).toBe(false);
});
});
Loading