Add environments (spaces) support
All checks were successful
Deploy to Server / deploy (push) Successful in 2m28s
All checks were successful
Deploy to Server / deploy (push) Successful in 2m28s
- API client sends _environment param on all content/asset requests - GET /api/environments endpoint to list configured environments - EnvironmentSwitcher in Sidebar with i18n label "Space" - clearSession() also clears environment from sessionStorage - .env.example documents ADMIN_USERNAME/PASSWORD/SESSION_SECRET Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,9 @@
|
||||
"invalidCredentials": "Ungültige Zugangsdaten",
|
||||
"networkError": "Netzwerkfehler, bitte erneut versuchen"
|
||||
},
|
||||
"EnvironmentSwitcher": {
|
||||
"label": "Space"
|
||||
},
|
||||
"Sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
"types": "Typen",
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"invalidCredentials": "Invalid credentials",
|
||||
"networkError": "Network error, please try again"
|
||||
},
|
||||
"EnvironmentSwitcher": {
|
||||
"label": "Space"
|
||||
},
|
||||
"Sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
"types": "Types",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { fetchCollections, fetchContentList, getApiKey, clearSession } from "@/lib/api";
|
||||
import { LocaleSwitcher } from "./LocaleSwitcher";
|
||||
import { EnvironmentSwitcher } from "./EnvironmentSwitcher";
|
||||
|
||||
const navLinkClass =
|
||||
"inline-flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium min-h-[44px] md:min-h-0 md:py-2";
|
||||
@@ -101,6 +102,9 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-2 pb-1">
|
||||
<EnvironmentSwitcher />
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<Link
|
||||
href="/"
|
||||
|
||||
@@ -47,6 +47,7 @@ export function collapseAssetUrlsForAdmin(
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "rustycms_admin_api_key";
|
||||
const ENVIRONMENT_STORAGE_KEY = "rustycms_admin_environment";
|
||||
const PER_PAGE_KEY = "rustycms_per_page";
|
||||
const DEFAULT_PER_PAGE = 25;
|
||||
const PER_PAGE_OPTIONS = [10, 25, 50, 100] as const;
|
||||
@@ -81,6 +82,35 @@ export function syncStoredApiKey(): void {
|
||||
if (stored) clientApiKey = stored;
|
||||
}
|
||||
|
||||
/** Current environment (space) for content/assets. When set, all content/asset API calls include _environment. */
|
||||
let currentEnvironment: string | null = null;
|
||||
|
||||
export function getCurrentEnvironment(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return currentEnvironment ?? sessionStorage.getItem(ENVIRONMENT_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function setCurrentEnvironment(env: string | null): void {
|
||||
currentEnvironment = env;
|
||||
if (typeof window !== "undefined") {
|
||||
if (env) sessionStorage.setItem(ENVIRONMENT_STORAGE_KEY, env);
|
||||
else sessionStorage.removeItem(ENVIRONMENT_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export type EnvironmentsResponse = {
|
||||
environments: string[];
|
||||
default: string | null;
|
||||
};
|
||||
|
||||
export async function fetchEnvironments(): Promise<EnvironmentsResponse> {
|
||||
try {
|
||||
const res = await fetch(`${getBaseUrl()}/api/environments`, { headers: getHeaders() });
|
||||
if (res.ok) return res.json();
|
||||
} catch { /* API not reachable */ }
|
||||
return { environments: [], default: null };
|
||||
}
|
||||
|
||||
/** Items per page for content lists (stored in localStorage). */
|
||||
export function getPerPage(): number {
|
||||
if (typeof window === "undefined") return DEFAULT_PER_PAGE;
|
||||
@@ -100,9 +130,10 @@ export function setPerPage(n: number): void {
|
||||
|
||||
export { PER_PAGE_OPTIONS, DEFAULT_PER_PAGE };
|
||||
|
||||
/** Clear API key and all rustycms_* localStorage (e.g. for shared devices). */
|
||||
/** Clear API key, current environment, and all rustycms_* storage (e.g. for shared devices). */
|
||||
export function clearSession(): void {
|
||||
setApiKey(null);
|
||||
setCurrentEnvironment(null);
|
||||
if (typeof window === "undefined") return;
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
@@ -110,6 +141,10 @@ export function clearSession(): void {
|
||||
if (k?.startsWith("rustycms_")) keys.push(k);
|
||||
}
|
||||
keys.forEach((k) => localStorage.removeItem(k));
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const k = sessionStorage.key(i);
|
||||
if (k?.startsWith("rustycms_")) sessionStorage.removeItem(k);
|
||||
}
|
||||
}
|
||||
|
||||
/** Check backend health (GET /health). */
|
||||
@@ -281,11 +316,13 @@ export type SlugCheckResponse = {
|
||||
export async function checkSlug(
|
||||
collection: string,
|
||||
slug: string,
|
||||
params: { exclude?: string; _locale?: string } = {}
|
||||
params: { exclude?: string; _locale?: string; _environment?: string } = {}
|
||||
): Promise<SlugCheckResponse> {
|
||||
const sp = new URLSearchParams({ slug: slug.trim() });
|
||||
if (params.exclude) sp.set("exclude", params.exclude);
|
||||
if (params._locale) sp.set("_locale", params._locale);
|
||||
const env = params._environment ?? getCurrentEnvironment();
|
||||
if (env) sp.set("_environment", env);
|
||||
const res = await fetch(
|
||||
`${getBaseUrl()}/api/collections/${encodeURIComponent(collection)}/slug-check?${sp}`,
|
||||
{ headers: getHeaders() }
|
||||
@@ -301,6 +338,7 @@ export type ContentListParams = {
|
||||
_order?: "asc" | "desc";
|
||||
_resolve?: string;
|
||||
_locale?: string;
|
||||
_environment?: string;
|
||||
[key: string]: string | number | undefined;
|
||||
};
|
||||
|
||||
@@ -309,7 +347,10 @@ export async function fetchContentList<T = Record<string, unknown>>(
|
||||
params: ContentListParams = {}
|
||||
): Promise<ListResponse<T>> {
|
||||
const sp = new URLSearchParams();
|
||||
const env = params._environment ?? getCurrentEnvironment();
|
||||
if (env) sp.set("_environment", env);
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (k === "_environment") return;
|
||||
if (v !== undefined && v !== "") sp.set(k, String(v));
|
||||
});
|
||||
const qs = sp.toString();
|
||||
@@ -322,11 +363,13 @@ export async function fetchContentList<T = Record<string, unknown>>(
|
||||
export async function fetchEntry<T = Record<string, unknown>>(
|
||||
collection: string,
|
||||
slug: string,
|
||||
params: { _resolve?: string; _locale?: string } = {}
|
||||
params: { _resolve?: string; _locale?: string; _environment?: string } = {}
|
||||
): Promise<T> {
|
||||
const sp = new URLSearchParams();
|
||||
if (params._resolve) sp.set("_resolve", params._resolve);
|
||||
if (params._locale) sp.set("_locale", params._locale);
|
||||
const env = params._environment ?? getCurrentEnvironment();
|
||||
if (env) sp.set("_environment", env);
|
||||
const qs = sp.toString();
|
||||
const url = `${getBaseUrl()}/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}${qs ? `?${qs}` : ""}`;
|
||||
const res = await fetch(url, { headers: getHeaders() });
|
||||
@@ -344,9 +387,12 @@ export type Referrer = {
|
||||
|
||||
export async function fetchReferrers(
|
||||
collection: string,
|
||||
slug: string
|
||||
slug: string,
|
||||
params: { _environment?: string } = {}
|
||||
): Promise<Referrer[]> {
|
||||
const url = `${getBaseUrl()}/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}/referrers`;
|
||||
const env = params._environment ?? getCurrentEnvironment();
|
||||
const qs = env ? `?_environment=${encodeURIComponent(env)}` : "";
|
||||
const url = `${getBaseUrl()}/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}/referrers${qs}`;
|
||||
const res = await fetch(url, { headers: getHeaders() });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
@@ -356,10 +402,12 @@ export async function fetchReferrers(
|
||||
export async function createEntry(
|
||||
collection: string,
|
||||
data: Record<string, unknown>,
|
||||
params: { _locale?: string } = {}
|
||||
params: { _locale?: string; _environment?: string } = {}
|
||||
): Promise<Record<string, unknown>> {
|
||||
const sp = new URLSearchParams();
|
||||
if (params._locale) sp.set("_locale", params._locale);
|
||||
const env = params._environment ?? getCurrentEnvironment();
|
||||
if (env) sp.set("_environment", env);
|
||||
const qs = sp.toString();
|
||||
const url = `${getBaseUrl()}/api/content/${encodeURIComponent(collection)}${qs ? `?${qs}` : ""}`;
|
||||
const res = await fetch(url, {
|
||||
@@ -381,10 +429,12 @@ export async function updateEntry(
|
||||
collection: string,
|
||||
slug: string,
|
||||
data: Record<string, unknown>,
|
||||
params: { _locale?: string } = {}
|
||||
params: { _locale?: string; _environment?: string } = {}
|
||||
): Promise<Record<string, unknown>> {
|
||||
const sp = new URLSearchParams();
|
||||
if (params._locale) sp.set("_locale", params._locale);
|
||||
const env = params._environment ?? getCurrentEnvironment();
|
||||
if (env) sp.set("_environment", env);
|
||||
const qs = sp.toString();
|
||||
const url = `${getBaseUrl()}/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}${qs ? `?${qs}` : ""}`;
|
||||
const res = await fetch(url, {
|
||||
@@ -432,19 +482,25 @@ export type FoldersResponse = {
|
||||
export async function fetchAssets(folder?: string): Promise<AssetsResponse> {
|
||||
const url = new URL(`${getBaseUrl()}/api/assets`);
|
||||
if (folder !== undefined) url.searchParams.set("folder", folder);
|
||||
const env = getCurrentEnvironment();
|
||||
if (env) url.searchParams.set("_environment", env);
|
||||
const res = await fetch(url.toString(), { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error(`Assets: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchFolders(): Promise<FoldersResponse> {
|
||||
const res = await fetch(`${getBaseUrl()}/api/assets/folders`, { headers: getHeaders() });
|
||||
const env = getCurrentEnvironment();
|
||||
const qs = env ? `?_environment=${encodeURIComponent(env)}` : "";
|
||||
const res = await fetch(`${getBaseUrl()}/api/assets/folders${qs}`, { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error(`Folders: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createFolder(name: string): Promise<void> {
|
||||
const res = await fetch(`${getBaseUrl()}/api/assets/folders`, {
|
||||
const env = getCurrentEnvironment();
|
||||
const qs = env ? `?_environment=${encodeURIComponent(env)}` : "";
|
||||
const res = await fetch(`${getBaseUrl()}/api/assets/folders${qs}`, {
|
||||
method: "POST",
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ name }),
|
||||
@@ -456,7 +512,9 @@ export async function createFolder(name: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function deleteFolder(name: string): Promise<void> {
|
||||
const res = await fetch(`${getBaseUrl()}/api/assets/folders/${encodeURIComponent(name)}`, {
|
||||
const env = getCurrentEnvironment();
|
||||
const qs = env ? `?_environment=${encodeURIComponent(env)}` : "";
|
||||
const res = await fetch(`${getBaseUrl()}/api/assets/folders/${encodeURIComponent(name)}${qs}`, {
|
||||
method: "DELETE",
|
||||
headers: getHeaders(),
|
||||
});
|
||||
@@ -476,6 +534,8 @@ const getUploadHeaders = (): HeadersInit => {
|
||||
export async function uploadAsset(file: File, folder?: string): Promise<Asset> {
|
||||
const url = new URL(`${getBaseUrl()}/api/assets`);
|
||||
if (folder) url.searchParams.set("folder", folder);
|
||||
const env = getCurrentEnvironment();
|
||||
if (env) url.searchParams.set("_environment", env);
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
const res = await fetch(url.toString(), { method: "POST", headers: getUploadHeaders(), body });
|
||||
@@ -488,7 +548,9 @@ export async function uploadAsset(file: File, folder?: string): Promise<Asset> {
|
||||
|
||||
/** path = "hero.jpg" or "blog/hero.jpg" */
|
||||
export async function deleteAsset(path: string): Promise<void> {
|
||||
const res = await fetch(`${getBaseUrl()}/api/assets/${path}`, {
|
||||
const env = getCurrentEnvironment();
|
||||
const qs = env ? `?_environment=${encodeURIComponent(env)}` : "";
|
||||
const res = await fetch(`${getBaseUrl()}/api/assets/${path}${qs}`, {
|
||||
method: "DELETE",
|
||||
headers: getHeaders(),
|
||||
});
|
||||
@@ -500,7 +562,9 @@ export async function deleteAsset(path: string): Promise<void> {
|
||||
|
||||
/** Rename asset; path = "hero.jpg" or "blog/hero.jpg", newFilename = "newname.jpg" */
|
||||
export async function renameAsset(path: string, newFilename: string): Promise<Asset> {
|
||||
const res = await fetch(`${getBaseUrl()}/api/assets/${path}`, {
|
||||
const env = getCurrentEnvironment();
|
||||
const qs = env ? `?_environment=${encodeURIComponent(env)}` : "";
|
||||
const res = await fetch(`${getBaseUrl()}/api/assets/${path}${qs}`, {
|
||||
method: "PATCH",
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ filename: newFilename }),
|
||||
@@ -590,10 +654,12 @@ export async function fetchLocales(): Promise<LocalesResponse> {
|
||||
export async function deleteEntry(
|
||||
collection: string,
|
||||
slug: string,
|
||||
params: { _locale?: string } = {}
|
||||
params: { _locale?: string; _environment?: string } = {}
|
||||
): Promise<void> {
|
||||
const sp = new URLSearchParams();
|
||||
if (params._locale) sp.set("_locale", params._locale);
|
||||
const env = params._environment ?? getCurrentEnvironment();
|
||||
if (env) sp.set("_environment", env);
|
||||
const qs = sp.toString();
|
||||
const url = `${getBaseUrl()}/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}${qs ? `?${qs}` : ""}`;
|
||||
const res = await fetch(url, {
|
||||
|
||||
Reference in New Issue
Block a user