diff --git a/app/apple-icon.png/route.ts b/app/apple-icon.png/route.ts
index 06e8288..95cef18 100644
--- a/app/apple-icon.png/route.ts
+++ b/app/apple-icon.png/route.ts
@@ -1,6 +1,4 @@
-import { NextResponse } from "next/server";
-
-import { buildFallbackIconResponse, getDynamicSiteIconUrls } from "@/lib/site-icons";
+import { buildSiteIconResponse, getDynamicSiteIconUrls } from "@/lib/site-icons";
export const dynamic = "force-dynamic";
export const revalidate = 0;
@@ -8,13 +6,5 @@ export const revalidate = 0;
export async function GET() {
const iconUrls = await getDynamicSiteIconUrls();
- if (iconUrls.faviconAssetUrl) {
- const response = NextResponse.redirect(iconUrls.faviconAssetUrl, 307);
-
- response.headers.set("Cache-Control", "no-store, max-age=0");
-
- return response;
- }
-
- return buildFallbackIconResponse(iconUrls.siteName, 180);
+ return buildSiteIconResponse(iconUrls.faviconAssetUrl);
}
diff --git a/app/favicon.ico/route.ts b/app/favicon.ico/route.ts
index a833a32..95cef18 100644
--- a/app/favicon.ico/route.ts
+++ b/app/favicon.ico/route.ts
@@ -1,6 +1,4 @@
-import { NextResponse } from "next/server";
-
-import { buildFallbackIconResponse, getDynamicSiteIconUrls } from "@/lib/site-icons";
+import { buildSiteIconResponse, getDynamicSiteIconUrls } from "@/lib/site-icons";
export const dynamic = "force-dynamic";
export const revalidate = 0;
@@ -8,13 +6,5 @@ export const revalidate = 0;
export async function GET() {
const iconUrls = await getDynamicSiteIconUrls();
- if (iconUrls.faviconAssetUrl) {
- const response = NextResponse.redirect(iconUrls.faviconAssetUrl, 307);
-
- response.headers.set("Cache-Control", "no-store, max-age=0");
-
- return response;
- }
-
- return buildFallbackIconResponse(iconUrls.siteName, 32);
+ return buildSiteIconResponse(iconUrls.faviconAssetUrl);
}
diff --git a/app/root/site-settings/page.tsx b/app/root/site-settings/page.tsx
index 991a965..26ab7aa 100644
--- a/app/root/site-settings/page.tsx
+++ b/app/root/site-settings/page.tsx
@@ -64,6 +64,7 @@ export default async function RootSiteSettingsPage({
headerTitle={copy.title}
headerDescription={copy.subtitle}
saveFormId="site-settings-form"
+ reloadDocumentOnSave
>
{searchParams?.success ? (
diff --git a/components/root/form-save-button.tsx b/components/root/form-save-button.tsx
index a9b6683..a84e1df 100644
--- a/components/root/form-save-button.tsx
+++ b/components/root/form-save-button.tsx
@@ -12,6 +12,7 @@ type FormSaveButtonProps = {
formSelector?: string;
formSelectors?: string[];
label?: string;
+ reloadDocumentOnSuccess?: boolean;
};
function serializeForm(form: HTMLFormElement) {
@@ -29,6 +30,7 @@ export function FormSaveButton({
formSelector,
formSelectors,
label = "Speichern",
+ reloadDocumentOnSuccess = false,
}: FormSaveButtonProps) {
const pathname = usePathname();
const router = useRouter();
@@ -220,6 +222,11 @@ export function FormSaveButton({
}
pendingSubmissionRef.current = null;
+ if (reloadDocumentOnSuccess) {
+ window.location.reload();
+ return;
+ }
+
router.refresh();
if (!searchParams.has("__saved")) {
@@ -233,7 +240,7 @@ export function FormSaveButton({
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, {
scroll: false,
});
- }, [currentUrl, pathname, router, searchParams]);
+ }, [currentUrl, pathname, reloadDocumentOnSuccess, router, searchParams]);
return (
-
-
- onChange({ ...value, label: event.target.value })}
- placeholder="Homepage Hero"
- />
-
+ {value.mode !== "library" ? (
+
+
+ onChange({ ...value, label: event.target.value, isCleared: false })}
+ placeholder="Homepage Hero"
+ />
+
+ ) : null}
{value.mode === "upload" ? (
@@ -116,27 +188,71 @@ export function MediaFieldPicker({
onChange({ ...value, url: event.target.value })}
+ onChange={(event) => onChange({ ...value, url: event.target.value, isCleared: false })}
placeholder="https://example.com/image.jpg"
/>
) : null}
{value.mode === "library" ? (
-
+
-
+
+
+
+ setLibraryQuery(event.target.value)}
+ className="pl-9"
+ placeholder="Search media"
+ />
+
+
+
+ {visibleLibraryOptions.length > 0 ? (
+ visibleLibraryOptions.map((option) => {
+ const isActive = option.id === value.assetId;
+
+ return (
+
+ onChange({
+ ...value,
+ assetId: option.id,
+ label: option.label,
+ isCleared: false,
+ })
+ }
+ className={cn(
+ "flex w-full items-center gap-3 rounded-nested border px-3 py-2 text-left transition-colors",
+ isActive
+ ? "border-border-strong bg-background"
+ : "border-border bg-background/70 hover:border-border-strong hover:bg-background",
+ )}
+ >
+ {option.url ? (
+
+ ) : (
+
+ )}
+
+
{option.label}
+
{option.source}
+
+ {isActive ? : null}
+
+ );
+ })
+ ) : (
+
+ No media found.
+
+ )}
+
+
) : null}
diff --git a/components/root/root-dashboard-shell.tsx b/components/root/root-dashboard-shell.tsx
index 02c24d5..04bc9ab 100644
--- a/components/root/root-dashboard-shell.tsx
+++ b/components/root/root-dashboard-shell.tsx
@@ -48,6 +48,7 @@ type RootDashboardShellProps = {
saveFormId?: string;
saveFormSelector?: string;
saveButtonLabel?: string;
+ reloadDocumentOnSave?: boolean;
headerActions?: ReactNode;
sidebarTopContent?: ReactNode;
toolbar?: ReactNode;
@@ -64,6 +65,7 @@ export async function RootDashboardShell({
saveFormId,
saveFormSelector,
saveButtonLabel,
+ reloadDocumentOnSave = false,
headerActions,
sidebarTopContent,
toolbar,
@@ -98,6 +100,7 @@ export async function RootDashboardShell({
formIds={saveFormId ? ["sidebar-maintenance-form", saveFormId] : ["sidebar-maintenance-form"]}
formSelectors={saveFormSelector ? [saveFormSelector] : undefined}
label={saveButtonLabel ?? "Speichern"}
+ reloadDocumentOnSuccess={reloadDocumentOnSave}
/>
>
diff --git a/components/root/site-settings-form.tsx b/components/root/site-settings-form.tsx
index f2852fd..e106fe3 100644
--- a/components/root/site-settings-form.tsx
+++ b/components/root/site-settings-form.tsx
@@ -65,6 +65,7 @@ function createImageFieldState(
url: "",
label,
kind: MediaKind.IMAGE,
+ isCleared: false,
};
}
@@ -75,6 +76,7 @@ function createImageFieldState(
url,
label,
kind: MediaKind.IMAGE,
+ isCleared: false,
};
}
@@ -84,6 +86,7 @@ function createImageFieldState(
url: "",
label,
kind: MediaKind.IMAGE,
+ isCleared: false,
};
}
@@ -92,6 +95,10 @@ function getMediaPreviewUrl(
options: MediaOption[],
fallbackUrl: string | null | undefined,
) {
+ if (value.isCleared) {
+ return "";
+ }
+
if (value.mode === "external") {
return value.url || fallbackUrl || "";
}
@@ -280,12 +287,20 @@ export function SiteSettingsForm({
value={favicon}
onChange={setFavicon}
options={mediaOptions}
+ hasInitialValue={Boolean(initialBindings.favicon?.assetId || initialBindings.favicon?.url)}
inputName="faviconMedia"
fileFieldName="faviconFile"
fileLabel="Upload Favicon"
- externalLabel="Favicon URL"
libraryLabel="Favicon Library"
accept=".png,.svg,.ico,image/png,image/svg+xml,image/x-icon,image/vnd.microsoft.icon"
+ allowExternal={false}
+ allowClear
+ clearLabel="Remove Favicon"
+ emptyValue={{
+ mode: "upload",
+ assetId: "",
+ url: "",
+ }}
/>
diff --git a/lib/app-config.ts b/lib/app-config.ts
index 896bc1c..ce93961 100644
--- a/lib/app-config.ts
+++ b/lib/app-config.ts
@@ -98,7 +98,9 @@ export async function getSiteSettingsMediaBindings(): Promise
= {
+ ".ico": "image/x-icon",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".png": "image/png",
+ ".svg": "image/svg+xml",
+ ".webp": "image/webp",
+};
type SiteIconInput = {
siteName: string;
- faviconAssetId?: string | null;
+ faviconVersion?: string | null;
faviconUrl?: string | null;
};
@@ -37,15 +50,9 @@ function appendVersionToUrl(url: string, version: string): string {
return resolved.toString();
}
-function getFallbackLabel(siteName: string): string {
- const firstCharacter = siteName.trim().charAt(0).toUpperCase() || "M";
-
- return firstCharacter;
-}
-
export function buildSiteIconUrls(input: SiteIconInput): SiteIconUrls {
const siteName = input.siteName.trim() || "Moh";
- const version = input.faviconAssetId?.trim() || DEFAULT_ICON_VERSION;
+ const version = input.faviconVersion?.trim() || DEFAULT_ICON_VERSION;
return {
siteName,
@@ -63,40 +70,44 @@ export async function getDynamicSiteIconUrls(): Promise {
return buildSiteIconUrls({
siteName: defaultLocaleSettings.siteName,
- faviconAssetId: bindings.favicon?.assetId,
+ faviconVersion: bindings.favicon?.version,
faviconUrl: bindings.favicon?.url,
});
}
-export function buildFallbackIconResponse(siteName: string, size: number) {
- const label = getFallbackLabel(siteName);
+function getResolvedPathname(iconUrl: string) {
+ const resolvedUrl = new URL(iconUrl, "https://local.invalid");
- return new ImageResponse(
- (
-
- {label}
-
- ),
- {
- width: size,
- height: size,
- headers: {
- "Cache-Control": "no-store, max-age=0",
- },
+ return resolvedUrl.pathname;
+}
+
+async function createFileResponse(absolutePath: string) {
+ const fileBuffer = await readFile(absolutePath);
+ const contentType = ICON_CONTENT_TYPES[path.extname(absolutePath).toLowerCase()] ?? "application/octet-stream";
+
+ return new Response(fileBuffer, {
+ status: 200,
+ headers: {
+ "Content-Type": contentType,
+ "Cache-Control": "no-store, max-age=0",
},
- );
+ });
+}
+
+export async function buildSiteIconResponse(iconUrl: string | null) {
+ if (iconUrl) {
+ const pathname = getResolvedPathname(iconUrl);
+
+ if (isManagedMediaFilePath(pathname)) {
+ return createFileResponse(resolveMediaUploadPath(pathname));
+ }
+ }
+
+ return new Response(Buffer.from(TRANSPARENT_PNG_BASE64, "base64"), {
+ status: 200,
+ headers: {
+ "Content-Type": "image/png",
+ "Cache-Control": "no-store, max-age=0",
+ },
+ });
}
diff --git a/lib/site-settings.ts b/lib/site-settings.ts
index 121d6f6..379a609 100644
--- a/lib/site-settings.ts
+++ b/lib/site-settings.ts
@@ -26,6 +26,7 @@ export type SiteSettings = {
export type SiteSettingsMediaBinding = {
assetId: string;
url: string;
+ version: string;
};
export type SiteSettingsMediaBindings = {