create-nextblock 0.12.13 → 0.12.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/nextblock-template/app/(auth-pages)/sign-in/page.tsx +0 -13
- package/templates/nextblock-template/app/(auth-pages)/sign-up/SignUpForm.tsx +0 -14
- package/templates/nextblock-template/app/[slug]/page.tsx +12 -4
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +209 -101
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -3
- package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +1 -0
- package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +1 -1
- package/templates/nextblock-template/app/cms/interactions/page.tsx +1 -1
- package/templates/nextblock-template/app/cms/media/components/MediaPickerDialog.tsx +38 -35
- package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +14 -11
- package/templates/nextblock-template/app/cms/products/ProductFormClientShell.tsx +62 -14
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +2 -2
- package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +2 -2
- package/templates/nextblock-template/app/cms/revisions/actions.ts +5 -5
- package/templates/nextblock-template/app/cms/settings/languages/actions.ts +53 -1
- package/templates/nextblock-template/app/cms/settings/languages/components/LanguageDetectionPanel.tsx +188 -0
- package/templates/nextblock-template/app/cms/settings/languages/page.tsx +12 -1
- package/templates/nextblock-template/app/cms/users/actions.ts +0 -3
- package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +0 -2
- package/templates/nextblock-template/app/cms/users/page.tsx +0 -2
- package/templates/nextblock-template/app/layout.tsx +42 -1
- package/templates/nextblock-template/app/product/[slug]/page.tsx +12 -4
- package/templates/nextblock-template/app/profile/ProfileAccountSidebar.tsx +1 -1
- package/templates/nextblock-template/app/profile/account-data.ts +1 -1
- package/templates/nextblock-template/app/profile/account-types.ts +0 -1
- package/templates/nextblock-template/app/profile/page.tsx +0 -1
- package/templates/nextblock-template/app/providers.tsx +2 -0
- package/templates/nextblock-template/components/PostCommentsSection.tsx +2 -2
- package/templates/nextblock-template/components/ProductReviewsSection.tsx +2 -2
- package/templates/nextblock-template/components/header-auth.tsx +1 -1
- package/templates/nextblock-template/context/LanguageContext.tsx +22 -7
- package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +46 -43
- package/templates/nextblock-template/lib/custom-block-relation-registry.ts +3 -3
- package/templates/nextblock-template/lib/i18n/country-languages.ts +247 -0
- package/templates/nextblock-template/lib/i18n/detection.test.ts +197 -0
- package/templates/nextblock-template/lib/i18n/detection.ts +192 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +72 -37
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +141 -8
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
- package/templates/nextblock-template/components/GitHubLoginButton.tsx +0 -36
|
@@ -21,11 +21,13 @@ interface MediaUploadFormProps {
|
|
|
21
21
|
// And will use onUploadSuccess instead of router.refresh().
|
|
22
22
|
returnJustData?: boolean;
|
|
23
23
|
defaultFolder?: string; // Optional pre-populated folder
|
|
24
|
+
// If true, tightens spacing/heights so the form fits inside a modal on shorter screens.
|
|
25
|
+
compact?: boolean;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
import { useUploadFolder } from "../UploadFolderContext";
|
|
27
29
|
|
|
28
|
-
export default function MediaUploadForm({ onUploadSuccess, returnJustData, defaultFolder }: MediaUploadFormProps) {
|
|
30
|
+
export default function MediaUploadForm({ onUploadSuccess, returnJustData, defaultFolder, compact }: MediaUploadFormProps) {
|
|
29
31
|
const router = useRouter();
|
|
30
32
|
const [isPending, startTransition] = useTransition();
|
|
31
33
|
const [file, setFile] = useState<File | null>(null);
|
|
@@ -292,26 +294,27 @@ export default function MediaUploadForm({ onUploadSuccess, returnJustData, defau
|
|
|
292
294
|
};
|
|
293
295
|
|
|
294
296
|
return (
|
|
295
|
-
<div className=
|
|
296
|
-
<div role="group" aria-label="Upload new media" className="space-y-4">
|
|
297
|
+
<div className={`border rounded-lg shadow-sm bg-card ${compact ? "p-4" : "p-6 mb-6"}`}>
|
|
298
|
+
<div role="group" aria-label="Upload new media" className={compact ? "space-y-3" : "space-y-4"}>
|
|
297
299
|
<div>
|
|
298
|
-
<
|
|
299
|
-
|
|
300
|
-
<div>
|
|
301
|
-
<Label htmlFor="media-folder" className="text-sm">Folder
|
|
300
|
+
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
|
|
301
|
+
<Label htmlFor="media-file" className="text-base font-medium">Upload New Media</Label>
|
|
302
|
+
<div className="flex items-center gap-2">
|
|
303
|
+
<Label htmlFor="media-folder" className="whitespace-nowrap text-sm text-muted-foreground">Upload Folder:</Label>
|
|
302
304
|
<Input
|
|
303
305
|
id="media-folder"
|
|
304
306
|
placeholder="uploads/"
|
|
305
307
|
value={folder}
|
|
306
308
|
onChange={(e) => setFolder(e.target.value)}
|
|
307
309
|
onKeyDown={handleFolderKeyDown}
|
|
310
|
+
className="h-9 w-44 sm:w-56"
|
|
308
311
|
/>
|
|
309
312
|
</div>
|
|
310
313
|
</div>
|
|
311
314
|
<div className="mt-2 flex items-center justify-center w-full">
|
|
312
315
|
<label
|
|
313
316
|
htmlFor="media-file-input"
|
|
314
|
-
className={`flex flex-col items-center justify-center w-full h-40 border-2 border-dashed rounded-lg cursor-pointer bg-muted/30 hover:bg-muted/50 transition-colors ${
|
|
317
|
+
className={`flex flex-col items-center justify-center w-full ${compact ? "h-28" : "h-40"} border-2 border-dashed rounded-lg cursor-pointer bg-muted/30 hover:bg-muted/50 transition-colors ${
|
|
315
318
|
isDraggingOver ? "border-primary bg-primary-foreground/20" : "border-input"
|
|
316
319
|
}`}
|
|
317
320
|
onDrop={handleDrop}
|
|
@@ -319,8 +322,8 @@ export default function MediaUploadForm({ onUploadSuccess, returnJustData, defau
|
|
|
319
322
|
onDragEnter={handleDragEnter}
|
|
320
323
|
onDragLeave={handleDragLeave}
|
|
321
324
|
>
|
|
322
|
-
<div className=
|
|
323
|
-
<UploadCloud className="w-10 h-10 mb-3
|
|
325
|
+
<div className={`flex flex-col items-center justify-center pointer-events-none ${compact ? "pt-3 pb-4" : "pt-5 pb-6"}`}> {/* pointer-events-none for children */}
|
|
326
|
+
<UploadCloud className={`text-muted-foreground ${compact ? "w-8 h-8 mb-2" : "w-10 h-10 mb-3"}`} />
|
|
324
327
|
<p className="mb-2 text-sm text-muted-foreground">
|
|
325
328
|
<span className="font-semibold">Click to upload</span> or drag and drop
|
|
326
329
|
</p>
|
|
@@ -332,7 +335,7 @@ export default function MediaUploadForm({ onUploadSuccess, returnJustData, defau
|
|
|
332
335
|
{previewUrl && file && file.type.startsWith("image/") && (
|
|
333
336
|
<div className="mt-4">
|
|
334
337
|
<Label>Preview:</Label>
|
|
335
|
-
<Image src={previewUrl} alt="Preview" width={300} height={192} className=
|
|
338
|
+
<Image src={previewUrl} alt="Preview" width={300} height={192} className={`mt-2 rounded-md w-auto object-contain border ${compact ? "max-h-32" : "max-h-48"}`} />
|
|
336
339
|
</div>
|
|
337
340
|
)}
|
|
338
341
|
{file && <p className="text-sm mt-2 text-muted-foreground">Selected: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)</p>}
|
|
@@ -3,32 +3,80 @@
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import { ProductForm } from '@nextblock-cms/ecommerce';
|
|
5
5
|
import MediaPickerDialog from '../media/components/MediaPickerDialog';
|
|
6
|
-
|
|
6
|
+
import DraftStatusActions from '../components/DraftStatusActions';
|
|
7
|
+
|
|
8
|
+
type ProductFormProps = React.ComponentProps<typeof ProductForm>;
|
|
9
|
+
type ProductUpdateAction = NonNullable<ProductFormProps['updateAction']>;
|
|
10
|
+
|
|
11
|
+
type ProductFormClientShellProps = ProductFormProps & {
|
|
12
|
+
/** Present only in edit mode; drives the "Unpublished Draft" toolbar. */
|
|
13
|
+
productId?: string;
|
|
14
|
+
/** Draft existence as computed on the server for the current render. */
|
|
15
|
+
serverHasDraft?: boolean;
|
|
16
|
+
};
|
|
7
17
|
|
|
8
18
|
const productFormSkeletonRows = ['details', 'description', 'media', 'inventory'];
|
|
9
19
|
|
|
10
|
-
export default function ProductFormClientShell(
|
|
20
|
+
export default function ProductFormClientShell({
|
|
21
|
+
productId,
|
|
22
|
+
serverHasDraft = false,
|
|
23
|
+
updateAction,
|
|
24
|
+
...props
|
|
25
|
+
}: ProductFormClientShellProps) {
|
|
11
26
|
const [isMounted, setIsMounted] = React.useState(false);
|
|
27
|
+
// The draft toolbar is gated on the server-computed `hasDraft`, but the form
|
|
28
|
+
// autosave writes a draft WITHOUT revalidating the route (revalidating would
|
|
29
|
+
// re-init the form and loop the autosave — see updateProductAction). Track
|
|
30
|
+
// draft existence on the client so the toolbar can appear the moment an
|
|
31
|
+
// autosave persists a draft, without a server refetch.
|
|
32
|
+
const [hasDraft, setHasDraft] = React.useState(serverHasDraft);
|
|
12
33
|
|
|
13
34
|
React.useEffect(() => {
|
|
14
35
|
setIsMounted(true);
|
|
15
36
|
}, []);
|
|
16
37
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
38
|
+
// Keep in sync with the server whenever it reports a draft (e.g. block edits
|
|
39
|
+
// in BlockEditorArea call router.refresh(), which re-runs the page). Only ever
|
|
40
|
+
// latch ON here — publish/discard reload the whole page, which resets state.
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
if (serverHasDraft) {
|
|
43
|
+
setHasDraft(true);
|
|
44
|
+
}
|
|
45
|
+
}, [serverHasDraft]);
|
|
46
|
+
|
|
47
|
+
// Reveal the toolbar as soon as a form autosave succeeds. The autosave already
|
|
48
|
+
// upserted a product_drafts row, so a draft now exists.
|
|
49
|
+
const wrappedUpdateAction = React.useCallback(
|
|
50
|
+
async (data: Parameters<ProductUpdateAction>[0]) => {
|
|
51
|
+
const result = await updateAction!(data);
|
|
52
|
+
setHasDraft(true);
|
|
53
|
+
return result;
|
|
54
|
+
},
|
|
55
|
+
[updateAction]
|
|
56
|
+
);
|
|
20
57
|
|
|
21
58
|
return (
|
|
22
|
-
|
|
23
|
-
{
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
59
|
+
<>
|
|
60
|
+
{productId ? (
|
|
61
|
+
<DraftStatusActions parentId={productId} parentType="product" hasDraft={hasDraft} />
|
|
62
|
+
) : null}
|
|
63
|
+
{isMounted ? (
|
|
64
|
+
<ProductForm
|
|
65
|
+
{...props}
|
|
66
|
+
hasOpenDraft={hasDraft}
|
|
67
|
+
updateAction={updateAction ? wrappedUpdateAction : undefined}
|
|
68
|
+
mediaPickerNode={
|
|
69
|
+
<MediaPickerDialog
|
|
70
|
+
triggerLabel="+ Add Image"
|
|
71
|
+
triggerVariant="outline"
|
|
72
|
+
defaultFolder="uploads/products/"
|
|
73
|
+
/>
|
|
74
|
+
}
|
|
29
75
|
/>
|
|
30
|
-
|
|
31
|
-
|
|
76
|
+
) : (
|
|
77
|
+
<ProductFormShellSkeleton />
|
|
78
|
+
)}
|
|
79
|
+
</>
|
|
32
80
|
);
|
|
33
81
|
}
|
|
34
82
|
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
DropdownMenuTrigger,
|
|
14
14
|
} from '@nextblock-cms/ui';
|
|
15
15
|
import ProductFormClientShell from '../../ProductFormClientShell';
|
|
16
|
-
import DraftStatusActions from '../../../components/DraftStatusActions';
|
|
17
16
|
import {
|
|
18
17
|
getCmsProduct,
|
|
19
18
|
getEnabledPaymentProviders,
|
|
@@ -191,7 +190,6 @@ export default async function EditProductPage({
|
|
|
191
190
|
|
|
192
191
|
return (
|
|
193
192
|
<div className="space-y-8 w-full max-w-[1400px] mx-auto px-6 py-8">
|
|
194
|
-
<DraftStatusActions parentId={product.id} parentType="product" hasDraft={hasDraft} />
|
|
195
193
|
<CortexAiPageContextRegistrar
|
|
196
194
|
context={{
|
|
197
195
|
contentType: 'product',
|
|
@@ -279,6 +277,8 @@ export default async function EditProductPage({
|
|
|
279
277
|
</div>
|
|
280
278
|
|
|
281
279
|
<ProductFormClientShell
|
|
280
|
+
productId={product.id}
|
|
281
|
+
serverHasDraft={hasDraft}
|
|
282
282
|
initialData={normalizedInitialData}
|
|
283
283
|
isEdit
|
|
284
284
|
availableLanguagesProp={languages}
|
|
@@ -31,7 +31,7 @@ type RevisionItem = {
|
|
|
31
31
|
revision_type: 'snapshot' | 'diff';
|
|
32
32
|
created_at: string;
|
|
33
33
|
author_id: string | null;
|
|
34
|
-
author?: { full_name?: string | null
|
|
34
|
+
author?: { full_name?: string | null } | null;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
import { Input } from "@nextblock-cms/ui";
|
|
@@ -223,7 +223,7 @@ export default function RevisionHistoryButton({ parentType, parentId }: Revision
|
|
|
223
223
|
<div className="rounded border divide-y">
|
|
224
224
|
{revisions.map((rev: RevisionItem, idx) => {
|
|
225
225
|
const when = rev.created_at ? formatDistanceToNow(new Date(rev.created_at), { addSuffix: true }) : '';
|
|
226
|
-
const authorName = rev.author?.full_name
|
|
226
|
+
const authorName = rev.author?.full_name;
|
|
227
227
|
const isCurrent = currentVersion != null && rev.version === currentVersion;
|
|
228
228
|
const isInitial = rev.version === 1;
|
|
229
229
|
|
|
@@ -12,7 +12,7 @@ type RevisionListItem = {
|
|
|
12
12
|
revision_type: 'snapshot' | 'diff';
|
|
13
13
|
created_at: string;
|
|
14
14
|
author_id: string | null;
|
|
15
|
-
author?: { full_name?: string | null
|
|
15
|
+
author?: { full_name?: string | null } | null;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
const REVISIONS_PER_PAGE = 10; // Reduced to 10 as requested
|
|
@@ -22,7 +22,7 @@ export async function listPageRevisions(pageId: number, page = 1, startDate?: st
|
|
|
22
22
|
|
|
23
23
|
let query = supabase
|
|
24
24
|
.from('page_revisions')
|
|
25
|
-
.select('id, page_id, author_id, version, revision_type, created_at, content, author:profiles(full_name
|
|
25
|
+
.select('id, page_id, author_id, version, revision_type, created_at, content, author:profiles(full_name)', { count: 'exact' })
|
|
26
26
|
.eq('page_id', pageId)
|
|
27
27
|
.order('version', { ascending: false })
|
|
28
28
|
.limit(1000); // Fetch up to 1000 recent revisions to allow dense pagination after filtering
|
|
@@ -105,7 +105,7 @@ export async function listPageRevisions(pageId: number, page = 1, startDate?: st
|
|
|
105
105
|
revision_type: 'snapshot',
|
|
106
106
|
created_at: pageCreatedAt ?? new Date().toISOString(),
|
|
107
107
|
author_id: null,
|
|
108
|
-
author: { full_name: 'System (Initial)'
|
|
108
|
+
author: { full_name: 'System (Initial)' },
|
|
109
109
|
has_changes: true, // V1 always counts
|
|
110
110
|
content: null
|
|
111
111
|
});
|
|
@@ -139,7 +139,7 @@ export async function listPostRevisions(postId: number, page = 1, startDate?: st
|
|
|
139
139
|
|
|
140
140
|
let query = supabase
|
|
141
141
|
.from('post_revisions')
|
|
142
|
-
.select('id, post_id, author_id, version, revision_type, created_at, content, author:profiles(full_name
|
|
142
|
+
.select('id, post_id, author_id, version, revision_type, created_at, content, author:profiles(full_name)', { count: 'exact' })
|
|
143
143
|
.eq('post_id', postId)
|
|
144
144
|
.order('version', { ascending: false })
|
|
145
145
|
.limit(1000);
|
|
@@ -221,7 +221,7 @@ export async function listPostRevisions(postId: number, page = 1, startDate?: st
|
|
|
221
221
|
revision_type: 'snapshot',
|
|
222
222
|
created_at: postCreatedAt ?? new Date().toISOString(),
|
|
223
223
|
author_id: null,
|
|
224
|
-
author: { full_name: 'System (Initial)'
|
|
224
|
+
author: { full_name: 'System (Initial)' },
|
|
225
225
|
has_changes: true,
|
|
226
226
|
content: null
|
|
227
227
|
});
|
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
"use server";
|
|
3
3
|
|
|
4
4
|
import { createClient } from "@nextblock-cms/db/server";
|
|
5
|
-
import { revalidatePath } from "next/cache";
|
|
5
|
+
import { revalidatePath, updateTag } from "next/cache";
|
|
6
6
|
import { redirect } from "next/navigation";
|
|
7
7
|
import type { Database } from "@nextblock-cms/db";
|
|
8
|
+
import {
|
|
9
|
+
LANGUAGE_DETECTION_SETTING_KEY,
|
|
10
|
+
LANGUAGE_DETECTION_CACHE_TAG,
|
|
11
|
+
normalizeLanguageDetectionSettings,
|
|
12
|
+
type LanguageDetectionSettings,
|
|
13
|
+
} from "../../../../lib/i18n/detection";
|
|
8
14
|
|
|
9
15
|
type Language = Database["public"]["Tables"]["languages"]["Row"];
|
|
10
16
|
|
|
@@ -260,3 +266,49 @@ export async function deleteLanguage(languageId: number) {
|
|
|
260
266
|
revalidatePath("/");
|
|
261
267
|
redirect("/cms/settings/languages?success=Language deleted successfully. All associated content has also been removed.");
|
|
262
268
|
}
|
|
269
|
+
|
|
270
|
+
// --- Language detection settings (site_settings.language_detection_settings) ---
|
|
271
|
+
|
|
272
|
+
export async function getLanguageDetectionSettings(): Promise<LanguageDetectionSettings> {
|
|
273
|
+
const supabase = createClient();
|
|
274
|
+
const { data, error } = await supabase
|
|
275
|
+
.from("site_settings")
|
|
276
|
+
.select("value")
|
|
277
|
+
.eq("key", LANGUAGE_DETECTION_SETTING_KEY)
|
|
278
|
+
.maybeSingle();
|
|
279
|
+
|
|
280
|
+
if (error) {
|
|
281
|
+
console.error("Error fetching language detection settings:", error);
|
|
282
|
+
}
|
|
283
|
+
// Absent row or error = defaults (browser detection, remembered choice).
|
|
284
|
+
return normalizeLanguageDetectionSettings(error ? null : data?.value);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function updateLanguageDetectionSettings(
|
|
288
|
+
input: LanguageDetectionSettings,
|
|
289
|
+
): Promise<{ success?: string; error?: string }> {
|
|
290
|
+
const supabase = createClient();
|
|
291
|
+
|
|
292
|
+
if (!(await verifyAdmin(supabase))) {
|
|
293
|
+
return { error: "Unauthorized: Admin role required." };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Never trust the client payload shape — coerce to a valid settings object.
|
|
297
|
+
const settings = normalizeLanguageDetectionSettings(input);
|
|
298
|
+
|
|
299
|
+
const { error } = await supabase
|
|
300
|
+
.from("site_settings")
|
|
301
|
+
.upsert({ key: LANGUAGE_DETECTION_SETTING_KEY, value: settings });
|
|
302
|
+
|
|
303
|
+
if (error) {
|
|
304
|
+
console.error("Error saving language detection settings:", error);
|
|
305
|
+
return { error: `Failed to save detection settings: ${error.message}` };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
updateTag(LANGUAGE_DETECTION_CACHE_TAG);
|
|
309
|
+
revalidatePath("/cms/settings/languages");
|
|
310
|
+
revalidatePath("/", "layout");
|
|
311
|
+
// The proxy caches detection config in-memory for up to a minute per worker,
|
|
312
|
+
// so the change isn't instant for new visitors — set that expectation here.
|
|
313
|
+
return { success: "Language detection settings saved. Changes reach new visitors within about a minute." };
|
|
314
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useTransition } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
Button,
|
|
6
|
+
Card,
|
|
7
|
+
CardContent,
|
|
8
|
+
CardDescription,
|
|
9
|
+
CardFooter,
|
|
10
|
+
CardHeader,
|
|
11
|
+
CardTitle,
|
|
12
|
+
Checkbox,
|
|
13
|
+
RadioGroup,
|
|
14
|
+
RadioGroupItem,
|
|
15
|
+
} from '@nextblock-cms/ui';
|
|
16
|
+
import { toast } from 'react-hot-toast';
|
|
17
|
+
import { Ban, Globe, Info, MapPin, Route } from 'lucide-react';
|
|
18
|
+
import type { LucideIcon } from 'lucide-react';
|
|
19
|
+
import { updateLanguageDetectionSettings } from '../actions';
|
|
20
|
+
import type {
|
|
21
|
+
LanguageDetectionMode,
|
|
22
|
+
LanguageDetectionSettings,
|
|
23
|
+
} from '../../../../../lib/i18n/detection';
|
|
24
|
+
|
|
25
|
+
interface ModeOption {
|
|
26
|
+
value: LanguageDetectionMode;
|
|
27
|
+
title: string;
|
|
28
|
+
description: string;
|
|
29
|
+
icon: LucideIcon;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const MODE_OPTIONS: ModeOption[] = [
|
|
33
|
+
{
|
|
34
|
+
value: 'browser',
|
|
35
|
+
title: 'Browser language',
|
|
36
|
+
description:
|
|
37
|
+
"Serve the language the visitor's browser prefers (Accept-Language header). Recommended — it reflects what each person actually reads.",
|
|
38
|
+
icon: Globe,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
value: 'country',
|
|
42
|
+
title: "Visitor's country (IP)",
|
|
43
|
+
description:
|
|
44
|
+
'Serve the main language of the country the visitor browses from, using the geolocation headers provided by your host (Vercel, Cloudflare, CloudFront).',
|
|
45
|
+
icon: MapPin,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
value: 'browser_then_country',
|
|
49
|
+
title: 'Browser language, then country',
|
|
50
|
+
description:
|
|
51
|
+
"Try the browser's preferred language first; when none of your languages match, fall back to the visitor's country.",
|
|
52
|
+
icon: Route,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
value: 'default',
|
|
56
|
+
title: 'No detection',
|
|
57
|
+
description:
|
|
58
|
+
'Always serve the default language to new visitors. They can still switch manually with the language switcher (shown when more than one language is active).',
|
|
59
|
+
icon: Ban,
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const COUNTRY_MODES: LanguageDetectionMode[] = ['country', 'browser_then_country'];
|
|
64
|
+
|
|
65
|
+
export default function LanguageDetectionPanel({
|
|
66
|
+
initialSettings,
|
|
67
|
+
activeLanguageCount,
|
|
68
|
+
}: {
|
|
69
|
+
initialSettings: LanguageDetectionSettings;
|
|
70
|
+
activeLanguageCount: number;
|
|
71
|
+
}) {
|
|
72
|
+
const [mode, setMode] = useState<LanguageDetectionMode>(initialSettings.mode);
|
|
73
|
+
const [rememberVisitorChoice, setRememberVisitorChoice] = useState<boolean>(
|
|
74
|
+
initialSettings.rememberVisitorChoice,
|
|
75
|
+
);
|
|
76
|
+
const [savedSettings, setSavedSettings] = useState<LanguageDetectionSettings>(initialSettings);
|
|
77
|
+
const [isPending, startTransition] = useTransition();
|
|
78
|
+
|
|
79
|
+
// With fewer than two active languages every mode resolves to the same locale,
|
|
80
|
+
// so the whole panel is a no-op and the public language switcher is hidden.
|
|
81
|
+
const isMoot = activeLanguageCount < 2;
|
|
82
|
+
|
|
83
|
+
const isDirty =
|
|
84
|
+
mode !== savedSettings.mode || rememberVisitorChoice !== savedSettings.rememberVisitorChoice;
|
|
85
|
+
|
|
86
|
+
const handleSave = () => {
|
|
87
|
+
startTransition(async () => {
|
|
88
|
+
const result = await updateLanguageDetectionSettings({ mode, rememberVisitorChoice });
|
|
89
|
+
if (result?.error) {
|
|
90
|
+
toast.error(result.error);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setSavedSettings({ mode, rememberVisitorChoice });
|
|
94
|
+
toast.success(result?.success ?? 'Language detection settings saved.');
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<Card>
|
|
100
|
+
<CardHeader>
|
|
101
|
+
<CardTitle>Language Detection</CardTitle>
|
|
102
|
+
<CardDescription>
|
|
103
|
+
How the site picks the first language for a new visitor. A language chosen with the
|
|
104
|
+
language switcher overrides detection for the rest of the visit — and for a year when
|
|
105
|
+
“Remember the visitor's language” is on. Changes reach new visitors
|
|
106
|
+
within about a minute.
|
|
107
|
+
</CardDescription>
|
|
108
|
+
</CardHeader>
|
|
109
|
+
<CardContent className="space-y-6">
|
|
110
|
+
{isMoot && (
|
|
111
|
+
<p className="flex items-start gap-2 rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
|
112
|
+
<Info className="mt-0.5 h-4 w-4 shrink-0" />
|
|
113
|
+
<span>
|
|
114
|
+
Language detection takes effect once at least two active languages are configured.
|
|
115
|
+
With a single language every visitor receives that language and the public language
|
|
116
|
+
switcher is hidden — you can still choose a mode here so it applies as soon as you add
|
|
117
|
+
another language.
|
|
118
|
+
</span>
|
|
119
|
+
</p>
|
|
120
|
+
)}
|
|
121
|
+
<RadioGroup
|
|
122
|
+
value={mode}
|
|
123
|
+
onValueChange={(value) => setMode(value as LanguageDetectionMode)}
|
|
124
|
+
className="grid gap-3 md:grid-cols-2"
|
|
125
|
+
>
|
|
126
|
+
{MODE_OPTIONS.map((option) => (
|
|
127
|
+
<label
|
|
128
|
+
key={option.value}
|
|
129
|
+
className="flex cursor-pointer items-start gap-3 rounded-md border p-4 transition-colors has-[[data-state=checked]]:border-primary has-[[data-state=checked]]:bg-primary/5 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2"
|
|
130
|
+
>
|
|
131
|
+
<RadioGroupItem
|
|
132
|
+
value={option.value}
|
|
133
|
+
className="mt-1"
|
|
134
|
+
aria-label={option.title}
|
|
135
|
+
aria-describedby={`detection-mode-desc-${option.value}`}
|
|
136
|
+
/>
|
|
137
|
+
<span className="space-y-1">
|
|
138
|
+
<span className="flex items-center gap-2 text-sm font-medium">
|
|
139
|
+
<option.icon className="h-4 w-4 text-muted-foreground" />
|
|
140
|
+
{option.title}
|
|
141
|
+
</span>
|
|
142
|
+
<span
|
|
143
|
+
id={`detection-mode-desc-${option.value}`}
|
|
144
|
+
className="block text-sm text-muted-foreground"
|
|
145
|
+
>
|
|
146
|
+
{option.description}
|
|
147
|
+
</span>
|
|
148
|
+
</span>
|
|
149
|
+
</label>
|
|
150
|
+
))}
|
|
151
|
+
</RadioGroup>
|
|
152
|
+
|
|
153
|
+
<div aria-live="polite">
|
|
154
|
+
{COUNTRY_MODES.includes(mode) && (
|
|
155
|
+
<p className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
|
156
|
+
Country detection relies on the geolocation header your hosting platform adds to each
|
|
157
|
+
request (Vercel, Cloudflare, and CloudFront do this automatically). When the header is
|
|
158
|
+
missing, visitors get the default language
|
|
159
|
+
{mode === 'browser_then_country' ? ' unless their browser language matches' : ''}.
|
|
160
|
+
</p>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<label className="flex cursor-pointer items-start gap-3">
|
|
165
|
+
<Checkbox
|
|
166
|
+
checked={rememberVisitorChoice}
|
|
167
|
+
onCheckedChange={(checked) => setRememberVisitorChoice(checked === true)}
|
|
168
|
+
className="mt-0.5"
|
|
169
|
+
aria-label="Remember the visitor's language"
|
|
170
|
+
aria-describedby="remember-visitor-desc"
|
|
171
|
+
/>
|
|
172
|
+
<span className="space-y-1">
|
|
173
|
+
<span className="block text-sm font-medium">Remember the visitor's language</span>
|
|
174
|
+
<span id="remember-visitor-desc" className="block text-sm text-muted-foreground">
|
|
175
|
+
Keep the detected or chosen language in a cookie for a year. When off, the language
|
|
176
|
+
only sticks for the browsing session and is detected again on the next visit.
|
|
177
|
+
</span>
|
|
178
|
+
</span>
|
|
179
|
+
</label>
|
|
180
|
+
</CardContent>
|
|
181
|
+
<CardFooter className="justify-end">
|
|
182
|
+
<Button onClick={handleSave} disabled={!isDirty || isPending}>
|
|
183
|
+
{isPending ? 'Saving...' : 'Save Detection Settings'}
|
|
184
|
+
</Button>
|
|
185
|
+
</CardFooter>
|
|
186
|
+
</Card>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
} from "@nextblock-cms/ui";
|
|
24
24
|
import type { Database } from "@nextblock-cms/db";
|
|
25
25
|
import DeleteLanguageClientButton from './components/DeleteLanguageButton';
|
|
26
|
+
import LanguageDetectionPanel from './components/LanguageDetectionPanel';
|
|
27
|
+
import { getLanguageDetectionSettings } from './actions';
|
|
26
28
|
|
|
27
29
|
type Language = Database['public']['Tables']['languages']['Row'];
|
|
28
30
|
|
|
@@ -41,7 +43,10 @@ async function getLanguages(): Promise<Language[]> {
|
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export default async function CmsLanguagesListPage() {
|
|
44
|
-
const languages = await
|
|
46
|
+
const [languages, detectionSettings] = await Promise.all([
|
|
47
|
+
getLanguages(),
|
|
48
|
+
getLanguageDetectionSettings(),
|
|
49
|
+
]);
|
|
45
50
|
// The following line for searchParams will cause an error during static generation or if window is not defined.
|
|
46
51
|
// It's better to pass searchParams as props if needed from the page component.
|
|
47
52
|
// For this specific page, success messages are handled by redirect query params which Next.js makes available in page props.
|
|
@@ -144,6 +149,12 @@ export default async function CmsLanguagesListPage() {
|
|
|
144
149
|
</Table>
|
|
145
150
|
</div>
|
|
146
151
|
)}
|
|
152
|
+
<div className="mt-6">
|
|
153
|
+
<LanguageDetectionPanel
|
|
154
|
+
initialSettings={detectionSettings}
|
|
155
|
+
activeLanguageCount={languages.filter((lang) => lang.is_active !== false).length}
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
147
158
|
<div className="mt-6">
|
|
148
159
|
<Alert variant="warning">
|
|
149
160
|
<ShieldAlert className="h-4 w-4" />
|
|
@@ -38,7 +38,6 @@ type UpdateUserProfilePayload = {
|
|
|
38
38
|
full_name?: string | null;
|
|
39
39
|
avatar_url?: string | null;
|
|
40
40
|
website?: string | null;
|
|
41
|
-
github_username?: string | null;
|
|
42
41
|
phone?: string | null;
|
|
43
42
|
};
|
|
44
43
|
|
|
@@ -97,7 +96,6 @@ export async function updateUserProfile(userIdToUpdate: string, formData: FormDa
|
|
|
97
96
|
full_name: formData.get("full_name") as string || null,
|
|
98
97
|
avatar_url: formData.get("avatar_url") as string || null,
|
|
99
98
|
website: formData.get("website") as string || null,
|
|
100
|
-
github_username: formData.get("github_username") as string || null,
|
|
101
99
|
phone: formData.get("phone") as string || null,
|
|
102
100
|
};
|
|
103
101
|
|
|
@@ -126,7 +124,6 @@ export async function updateUserProfile(userIdToUpdate: string, formData: FormDa
|
|
|
126
124
|
full_name: rawFormData.full_name,
|
|
127
125
|
avatar_url: rawFormData.avatar_url,
|
|
128
126
|
website: rawFormData.website,
|
|
129
|
-
github_username: rawFormData.github_username,
|
|
130
127
|
phone: rawFormData.phone,
|
|
131
128
|
};
|
|
132
129
|
|
|
@@ -30,7 +30,6 @@ export default function UserForm({ userToEditAuth, userToEditProfile, userToEdit
|
|
|
30
30
|
if (data.full_name !== undefined) formData.append('full_name', data.full_name || '');
|
|
31
31
|
if (data.avatar_url !== undefined) formData.append('avatar_url', data.avatar_url || '');
|
|
32
32
|
if (data.website !== undefined) formData.append('website', data.website || '');
|
|
33
|
-
if (data.github_username !== undefined) formData.append('github_username', data.github_username || '');
|
|
34
33
|
if (data.phone !== undefined) formData.append('phone', data.phone || '');
|
|
35
34
|
if (data.role !== undefined) formData.append('role', data.role);
|
|
36
35
|
|
|
@@ -48,7 +47,6 @@ export default function UserForm({ userToEditAuth, userToEditProfile, userToEdit
|
|
|
48
47
|
full_name: userToEditProfile?.full_name || '',
|
|
49
48
|
avatar_url: userToEditProfile?.avatar_url || '',
|
|
50
49
|
website: userToEditProfile?.website || '',
|
|
51
|
-
github_username: userToEditProfile?.github_username || '',
|
|
52
50
|
phone: userToEditProfile?.phone || '',
|
|
53
51
|
role: userToEditProfile?.role || 'USER',
|
|
54
52
|
billing_address: userToEditAddresses.billingAddress,
|
|
@@ -129,7 +129,6 @@ export default async function CmsUsersListPage() {
|
|
|
129
129
|
<TableHead className="w-[80px]">Avatar</TableHead>
|
|
130
130
|
<TableHead>Email</TableHead>
|
|
131
131
|
<TableHead>Full Name</TableHead>
|
|
132
|
-
<TableHead>GitHub</TableHead>
|
|
133
132
|
<TableHead>Role</TableHead>
|
|
134
133
|
<TableHead>Joined</TableHead>
|
|
135
134
|
<TableHead className="text-right w-[80px]">Actions</TableHead>
|
|
@@ -146,7 +145,6 @@ export default async function CmsUsersListPage() {
|
|
|
146
145
|
</TableCell>
|
|
147
146
|
<TableCell className="font-medium">{authUser.email}</TableCell>
|
|
148
147
|
<TableCell className="text-muted-foreground">{profile?.full_name || "N/A"}</TableCell>
|
|
149
|
-
<TableCell className="text-muted-foreground">{profile?.github_username || "-"}</TableCell>
|
|
150
148
|
<TableCell>
|
|
151
149
|
<Badge variant={
|
|
152
150
|
profile?.role === "ADMIN" ? "destructive" :
|