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
5 changes: 5 additions & 0 deletions .changeset/fix-admin-locale-edit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/admin": patch
---

Forward `locale` query param through admin content edit route and API client to resolve correct i18n variant for slug-based lookups (#1242)
5 changes: 5 additions & 0 deletions .changeset/fix-core-locale-write.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Read `?locale=` in content write routes (DELETE, publish, unpublish, discard-draft, schedule, unschedule) and forward it to `handleContentGet` for locale-aware slug resolution (#1242)
1 change: 1 addition & 0 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,7 @@ export function ContentEditor({
navigate({
to: "/content/$collection/$id",
params: { collection, id: tr.id },
search: { locale: tr.locale },
})
}
onCreate={onTranslate}
Expand Down
1 change: 1 addition & 0 deletions packages/admin/src/components/ContentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,7 @@ function ContentListItem({
<RouterLinkButton
to="/content/$collection/$id"
params={{ collection, id: item.id }}
search={{ locale: item.locale }}
aria-label={t`Edit ${title}`}
variant="ghost"
shape="square"
Expand Down
78 changes: 64 additions & 14 deletions packages/admin/src/lib/api/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,15 @@ export async function fetchContentList(
/**
* Fetch single content item
*/
export async function fetchContent(collection: string, id: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`);
export async function fetchContent(
collection: string,
id: string,
options?: { locale?: string },
): Promise<ContentItem> {
const params = new URLSearchParams();
if (options?.locale) params.set("locale", options.locale);
const query = params.toString() ? `?${params}` : "";
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}${query}`);
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to fetch content");
return data.item;
}
Expand Down Expand Up @@ -200,8 +207,12 @@ export async function updateContent(
collection: string,
id: string,
input: UpdateContentInput,
options?: { locale?: string },
): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`, {
const params = new URLSearchParams();
if (options?.locale) params.set("locale", options.locale);
const query = params.toString() ? `?${params}` : "";
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}${query}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
Expand All @@ -213,8 +224,15 @@ export async function updateContent(
/**
* Delete content (moves to trash)
*/
export async function deleteContent(collection: string, id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`, {
export async function deleteContent(
collection: string,
id: string,
options?: { locale?: string },
): Promise<void> {
const params = new URLSearchParams();
if (options?.locale) params.set("locale", options.locale);
const query = params.toString() ? `?${params}` : "";
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}${query}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to delete content`));
Expand Down Expand Up @@ -284,8 +302,12 @@ export async function scheduleContent(
collection: string,
id: string,
scheduledAt: string,
options?: { locale?: string },
): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule`, {
const params = new URLSearchParams();
if (options?.locale) params.set("locale", options.locale);
const query = params.toString() ? `?${params}` : "";
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule${query}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scheduledAt }),
Expand All @@ -300,8 +322,15 @@ export async function scheduleContent(
/**
* Unschedule content (revert to draft)
*/
export async function unscheduleContent(collection: string, id: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule`, {
export async function unscheduleContent(
collection: string,
id: string,
options?: { locale?: string },
): Promise<ContentItem> {
const params = new URLSearchParams();
if (options?.locale) params.set("locale", options.locale);
const query = params.toString() ? `?${params}` : "";
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule${query}`, {
method: "DELETE",
});
const data = await parseApiResponse<{ item: ContentItem }>(
Expand Down Expand Up @@ -366,8 +395,15 @@ export async function getPreviewUrl(
/**
* Publish content - promotes current draft to live
*/
export async function publishContent(collection: string, id: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/publish`, {
export async function publishContent(
collection: string,
id: string,
options?: { locale?: string },
): Promise<ContentItem> {
const params = new URLSearchParams();
if (options?.locale) params.set("locale", options.locale);
const query = params.toString() ? `?${params}` : "";
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/publish${query}`, {
method: "POST",
});
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to publish content");
Expand All @@ -377,8 +413,15 @@ export async function publishContent(collection: string, id: string): Promise<Co
/**
* Unpublish content - removes from public, preserves draft
*/
export async function unpublishContent(collection: string, id: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/unpublish`, {
export async function unpublishContent(
collection: string,
id: string,
options?: { locale?: string },
): Promise<ContentItem> {
const params = new URLSearchParams();
if (options?.locale) params.set("locale", options.locale);
const query = params.toString() ? `?${params}` : "";
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/unpublish${query}`, {
method: "POST",
});
const data = await parseApiResponse<{ item: ContentItem }>(
Expand All @@ -391,8 +434,15 @@ export async function unpublishContent(collection: string, id: string): Promise<
/**
* Discard draft changes - reverts to live version
*/
export async function discardDraft(collection: string, id: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/discard-draft`, {
export async function discardDraft(
collection: string,
id: string,
options?: { locale?: string },
): Promise<ContentItem> {
const params = new URLSearchParams();
if (options?.locale) params.set("locale", options.locale);
const query = params.toString() ? `?${params}` : "";
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/discard-draft${query}`, {
method: "POST",
});
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to discard draft");
Expand Down
68 changes: 49 additions & 19 deletions packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,10 @@ function patchAutosaveQueries(
data?: Record<string, unknown>;
slug?: string;
};
locale?: string;
},
) {
const { collection, id, savedItem, payload } = params;
const { collection, id, savedItem, payload, locale } = params;
const draftRevisionId = savedItem.draftRevisionId;

if (draftRevisionId) {
Expand All @@ -162,7 +163,10 @@ function patchAutosaveQueries(
});
}

queryClient.setQueryData<ContentItem>(["content", collection, id], savedItem);
queryClient.setQueryData<ContentItem>(
locale ? ["content", collection, id, { locale }] : ["content", collection, id],
savedItem,
);
}

// Create a base root route without Shell for setup
Expand Down Expand Up @@ -493,6 +497,7 @@ function ContentNewPage() {
void navigate({
to: "/content/$collection/$id",
params: { collection, id: result.id },
search: { locale: result.locale },
});
},
});
Expand Down Expand Up @@ -582,6 +587,7 @@ const contentEditRoute = createRoute({
component: ContentEditPage,
validateSearch: (search) => ({
...(typeof search.field === "string" && { field: search.field }),
...(typeof search.locale === "string" && { locale: search.locale }),
}),
});

Expand All @@ -606,10 +612,12 @@ function ContentEditPage() {
});

const i18n = manifest?.i18n;
const activeLocale = i18n ? (searchParams.locale ?? i18n.defaultLocale) : undefined;

const { data: rawItem, isLoading } = useQuery({
queryKey: ["content", collection, id],
queryFn: () => fetchContent(collection, id),
queryKey: ["content", collection, id, { locale: activeLocale }],
queryFn: () => fetchContent(collection, id, { locale: activeLocale }),
enabled: !i18n || !!activeLocale,
});

React.useEffect(() => {
Expand Down Expand Up @@ -725,9 +733,11 @@ function ContentEditPage() {
bylines?: BylineCreditInput[];
skipRevision?: boolean;
seo?: ContentSeoInput;
}) => updateContent(collection, id, data),
}) => updateContent(collection, id, data, { locale: rawItem?.locale ?? activeLocale }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["content", collection, id] });
void queryClient.invalidateQueries({
queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }],
});
// Also invalidate revisions since a new one was created
void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] });
// Invalidate the cached draft revision so stale data doesn't overwrite the form
Expand All @@ -753,7 +763,13 @@ function ContentEditPage() {
data?: Record<string, unknown>;
slug?: string;
bylines?: BylineCreditInput[];
}) => updateContent(collection, id, { ...data, skipRevision: true }),
}) =>
updateContent(
collection,
id,
{ ...data, skipRevision: true },
{ locale: rawItem?.locale ?? activeLocale },
),
onSuccess: (savedItem, variables) => {
patchAutosaveQueries(queryClient, {
collection,
Expand All @@ -763,6 +779,7 @@ function ContentEditPage() {
data: variables.data,
slug: variables.slug,
},
locale: rawItem?.locale ?? activeLocale,
});
setLastAutosaveAt(new Date());
// Keep the cache fresh without refetching older server state back into the form
Expand All @@ -778,9 +795,11 @@ function ContentEditPage() {
});

const publishMutation = useMutation({
mutationFn: () => publishContent(collection, id),
mutationFn: () => publishContent(collection, id, { locale: rawItem?.locale ?? activeLocale }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["content", collection, id] });
void queryClient.invalidateQueries({
queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }],
});
void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] });
toastManager.add({ title: t`Published`, description: t`Content is now live` });
},
Expand All @@ -794,9 +813,11 @@ function ContentEditPage() {
});

const unpublishMutation = useMutation({
mutationFn: () => unpublishContent(collection, id),
mutationFn: () => unpublishContent(collection, id, { locale: rawItem?.locale ?? activeLocale }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["content", collection, id] });
void queryClient.invalidateQueries({
queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }],
});
void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] });
toastManager.add({ title: t`Unpublished`, description: t`Content removed from public view` });
},
Expand All @@ -810,9 +831,11 @@ function ContentEditPage() {
});

const discardDraftMutation = useMutation({
mutationFn: () => discardDraft(collection, id),
mutationFn: () => discardDraft(collection, id, { locale: rawItem?.locale ?? activeLocale }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["content", collection, id] });
void queryClient.invalidateQueries({
queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }],
});
void queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] });
toastManager.add({
title: t`Changes discarded`,
Expand All @@ -829,9 +852,12 @@ function ContentEditPage() {
});

const scheduleMutation = useMutation({
mutationFn: (scheduledAt: string) => scheduleContent(collection, id, scheduledAt),
mutationFn: (scheduledAt: string) =>
scheduleContent(collection, id, scheduledAt, { locale: rawItem?.locale ?? activeLocale }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["content", collection, id] });
void queryClient.invalidateQueries({
queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }],
});
toastManager.add({
title: t`Scheduled`,
description: t`Content has been scheduled for publishing`,
Expand All @@ -847,9 +873,12 @@ function ContentEditPage() {
});

const unscheduleMutation = useMutation({
mutationFn: () => unscheduleContent(collection, id),
mutationFn: () =>
unscheduleContent(collection, id, { locale: rawItem?.locale ?? activeLocale }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["content", collection, id] });
void queryClient.invalidateQueries({
queryKey: ["content", collection, id, { locale: rawItem?.locale ?? activeLocale }],
});
toastManager.add({
title: t`Unscheduled`,
description: t`Content reverted to draft`,
Expand Down Expand Up @@ -879,6 +908,7 @@ function ContentEditPage() {
void navigate({
to: "/content/$collection/$id",
params: { collection, id: result.id },
search: { locale: result.locale },
});
toastManager.add({
title: t`Translation created`,
Expand All @@ -895,14 +925,14 @@ function ContentEditPage() {
});

const deleteMutation = useMutation({
mutationFn: () => deleteContent(collection, id),
mutationFn: () => deleteContent(collection, id, { locale: rawItem?.locale ?? activeLocale }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["content", collection] });
void queryClient.invalidateQueries({ queryKey: ["content", collection, "trash"] });
void navigate({
to: "/content/$collection",
params: { collection },
search: { locale: undefined },
search: { locale: activeLocale },
});
},
onError: (error) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export const PUT: APIRoute = async ({ params, request, locals, cache }) => {
return unwrapResult(result);
};

export const DELETE: APIRoute = async ({ params, locals, cache }) => {
export const DELETE: APIRoute = async ({ params, locals, url, cache }) => {
const { emdash, user } = locals;
const collection = params.collection!;
const id = params.id!;
Expand All @@ -141,8 +141,10 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => {
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
}

const locale = url.searchParams.get("locale") || undefined;

// Fetch item to check ownership
const existing = await emdash.handleContentGet(collection, id);
const existing = await emdash.handleContentGet(collection, id, locale);
if (!existing.success) {
return apiError(
existing.error?.code ?? "UNKNOWN_ERROR",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { apiError, mapErrorStatus, unwrapResult } from "#api/error.js";

export const prerender = false;

export const POST: APIRoute = async ({ params, locals, cache }) => {
export const POST: APIRoute = async ({ params, locals, url, cache }) => {
const { emdash, user } = locals;
const collection = params.collection!;
const id = params.id!;
Expand All @@ -20,8 +20,10 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
}

const locale = url.searchParams.get("locale") || undefined;

// Fetch item to check ownership
const existing = await emdash.handleContentGet(collection, id);
const existing = await emdash.handleContentGet(collection, id, locale);
if (!existing.success) {
return apiError(
existing.error?.code ?? "UNKNOWN_ERROR",
Expand Down
Loading
Loading