@hed-hog/content 0.0.186 → 0.0.190
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.
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { PageHeader } from '@/components/entity-list';
|
|
4
|
+
import { RichTextEditor } from '@/components/rich-text-editor';
|
|
5
|
+
import {
|
|
6
|
+
AlertDialog,
|
|
7
|
+
AlertDialogAction,
|
|
8
|
+
AlertDialogCancel,
|
|
9
|
+
AlertDialogContent,
|
|
10
|
+
AlertDialogDescription,
|
|
11
|
+
AlertDialogFooter,
|
|
12
|
+
AlertDialogHeader,
|
|
13
|
+
AlertDialogTitle,
|
|
14
|
+
AlertDialogTrigger,
|
|
15
|
+
} from '@/components/ui/alert-dialog';
|
|
16
|
+
import { Badge } from '@/components/ui/badge';
|
|
17
|
+
import { Button } from '@/components/ui/button';
|
|
18
|
+
import { Card, CardContent } from '@/components/ui/card';
|
|
19
|
+
import {
|
|
20
|
+
Dialog,
|
|
21
|
+
DialogContent,
|
|
22
|
+
DialogDescription,
|
|
23
|
+
DialogFooter,
|
|
24
|
+
DialogHeader,
|
|
25
|
+
DialogTitle,
|
|
26
|
+
} from '@/components/ui/dialog';
|
|
27
|
+
import {
|
|
28
|
+
Form,
|
|
29
|
+
FormControl,
|
|
30
|
+
FormField,
|
|
31
|
+
FormItem,
|
|
32
|
+
FormLabel,
|
|
33
|
+
FormMessage,
|
|
34
|
+
} from '@/components/ui/form';
|
|
35
|
+
import { Input } from '@/components/ui/input';
|
|
36
|
+
import { Label } from '@/components/ui/label';
|
|
37
|
+
import {
|
|
38
|
+
Select,
|
|
39
|
+
SelectContent,
|
|
40
|
+
SelectItem,
|
|
41
|
+
SelectTrigger,
|
|
42
|
+
SelectValue,
|
|
43
|
+
} from '@/components/ui/select';
|
|
44
|
+
import { useDebounce } from '@/hooks/use-debounce';
|
|
45
|
+
import { formatDateTime } from '@/lib/format-date';
|
|
46
|
+
import { cn } from '@/lib/utils';
|
|
47
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
48
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
49
|
+
import {
|
|
50
|
+
Calendar,
|
|
51
|
+
Edit,
|
|
52
|
+
Eye,
|
|
53
|
+
FileText,
|
|
54
|
+
Globe,
|
|
55
|
+
Languages,
|
|
56
|
+
Loader2,
|
|
57
|
+
Plus,
|
|
58
|
+
Search,
|
|
59
|
+
Trash2,
|
|
60
|
+
} from 'lucide-react';
|
|
61
|
+
import { useTranslations } from 'next-intl';
|
|
62
|
+
import { useEffect, useState } from 'react';
|
|
63
|
+
import { useForm } from 'react-hook-form';
|
|
64
|
+
import { toast } from 'sonner';
|
|
65
|
+
import { z } from 'zod';
|
|
66
|
+
|
|
67
|
+
type Content = {
|
|
68
|
+
id?: number;
|
|
69
|
+
content_id?: number;
|
|
70
|
+
slug: string;
|
|
71
|
+
status: 'draft' | 'published';
|
|
72
|
+
title?: string;
|
|
73
|
+
body?: string;
|
|
74
|
+
created_at?: string;
|
|
75
|
+
updated_at?: string;
|
|
76
|
+
content_locale?: Array<{
|
|
77
|
+
title: string;
|
|
78
|
+
body: string;
|
|
79
|
+
locale?: {
|
|
80
|
+
code: string;
|
|
81
|
+
name: string;
|
|
82
|
+
};
|
|
83
|
+
}>;
|
|
84
|
+
available_locales?: Array<{
|
|
85
|
+
id: number;
|
|
86
|
+
code: string;
|
|
87
|
+
name: string;
|
|
88
|
+
}>;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
type PaginationResult<T> = {
|
|
92
|
+
data: T[];
|
|
93
|
+
total: number;
|
|
94
|
+
page: number;
|
|
95
|
+
pageSize: number;
|
|
96
|
+
lastPage: number;
|
|
97
|
+
prev: number | null;
|
|
98
|
+
next: number | null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
type Stats = {
|
|
102
|
+
total: number;
|
|
103
|
+
totalPublished: number;
|
|
104
|
+
totalDraft: number;
|
|
105
|
+
totalEnabledLanguages: number;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type Locale = {
|
|
109
|
+
id: number;
|
|
110
|
+
code: string;
|
|
111
|
+
name: string;
|
|
112
|
+
enabled: boolean;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export default function ContentsPage() {
|
|
116
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
117
|
+
const debouncedSearch = useDebounce(searchTerm);
|
|
118
|
+
const [isFormDialogOpen, setIsFormDialogOpen] = useState(false);
|
|
119
|
+
const [editingContentId, setEditingContentId] = useState<number | null>(null);
|
|
120
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
121
|
+
const [selectedLocale, setSelectedLocale] = useState<string>('');
|
|
122
|
+
const { request, currentLocaleCode, getSettingValue } = useApp();
|
|
123
|
+
const t = useTranslations('content.ContentPage');
|
|
124
|
+
|
|
125
|
+
const statusOptions = [
|
|
126
|
+
{ value: 'draft', label: t('statusDraft'), color: 'bg-gray-500' },
|
|
127
|
+
{ value: 'published', label: t('statusPublished'), color: 'bg-green-500' },
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
const contentSchema = z.object({
|
|
131
|
+
slug: z.string().min(1, t('errorSlugRequired')),
|
|
132
|
+
status: z.enum(['draft', 'published']),
|
|
133
|
+
title: z.string().min(1, t('errorTitleRequired')),
|
|
134
|
+
body: z.string().min(1, t('errorContentRequired')),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const { data: locales = [] } = useQuery<Locale[]>({
|
|
138
|
+
queryKey: ['locales'],
|
|
139
|
+
queryFn: async () => {
|
|
140
|
+
const response = await request<PaginationResult<Locale>>({
|
|
141
|
+
url: '/locale',
|
|
142
|
+
params: {
|
|
143
|
+
pageSize: 100,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
return response.data?.data?.filter((l: Locale) => l.enabled) || [];
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const form = useForm<z.infer<typeof contentSchema>>({
|
|
151
|
+
resolver: zodResolver(contentSchema),
|
|
152
|
+
defaultValues: {
|
|
153
|
+
slug: '',
|
|
154
|
+
status: 'draft',
|
|
155
|
+
title: '',
|
|
156
|
+
body: '',
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const { data: stats, refetch: refetchStats } = useQuery<Stats>({
|
|
161
|
+
queryKey: ['content-stats', currentLocaleCode],
|
|
162
|
+
queryFn: async () => {
|
|
163
|
+
const response = await request({
|
|
164
|
+
url: '/content/stats',
|
|
165
|
+
});
|
|
166
|
+
return response.data as Stats;
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const { data: contents, refetch: refetch } = useQuery<
|
|
171
|
+
PaginationResult<Content>
|
|
172
|
+
>({
|
|
173
|
+
queryKey: ['content', currentLocaleCode],
|
|
174
|
+
queryFn: async () => {
|
|
175
|
+
const response = await request({
|
|
176
|
+
url: '/content',
|
|
177
|
+
params: {
|
|
178
|
+
search: debouncedSearch,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
return response.data as PaginationResult<Content>;
|
|
182
|
+
},
|
|
183
|
+
initialData: {
|
|
184
|
+
data: [],
|
|
185
|
+
total: 0,
|
|
186
|
+
page: 1,
|
|
187
|
+
pageSize: 10,
|
|
188
|
+
lastPage: 1,
|
|
189
|
+
prev: null,
|
|
190
|
+
next: null,
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const getStatusBadge = (status: string) => {
|
|
195
|
+
const statusOption = statusOptions.find((s) => s.value === status);
|
|
196
|
+
return statusOption || statusOptions[0];
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const handleNewContent = (): void => {
|
|
200
|
+
form.reset({
|
|
201
|
+
slug: '',
|
|
202
|
+
status: 'draft',
|
|
203
|
+
title: '',
|
|
204
|
+
body: '',
|
|
205
|
+
});
|
|
206
|
+
setSelectedLocale(currentLocaleCode);
|
|
207
|
+
setEditingContentId(null);
|
|
208
|
+
setIsFormDialogOpen(true);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const handleEditContent = async (content: Content): Promise<void> => {
|
|
212
|
+
try {
|
|
213
|
+
const response = await request<any>({
|
|
214
|
+
url: `/content/${content.content_id || content.id}`,
|
|
215
|
+
method: 'GET',
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const fullContent = response.data;
|
|
219
|
+
const allLocalesData: Record<string, { title: string; body: string }> =
|
|
220
|
+
fullContent.locales || {};
|
|
221
|
+
|
|
222
|
+
if (Object.keys(allLocalesData).length === 0) {
|
|
223
|
+
allLocalesData[currentLocaleCode] = {
|
|
224
|
+
title: '',
|
|
225
|
+
body: '',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const initialLocale = allLocalesData[currentLocaleCode]
|
|
230
|
+
? currentLocaleCode
|
|
231
|
+
: Object.keys(allLocalesData)[0] || currentLocaleCode;
|
|
232
|
+
|
|
233
|
+
if (!allLocalesData[initialLocale]) {
|
|
234
|
+
allLocalesData[initialLocale] = {
|
|
235
|
+
title: '',
|
|
236
|
+
body: '',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
setSelectedLocale(initialLocale);
|
|
241
|
+
(form as any).localesData = allLocalesData;
|
|
242
|
+
form.reset({
|
|
243
|
+
slug: fullContent.slug || '',
|
|
244
|
+
status: fullContent.status || 'draft',
|
|
245
|
+
title: allLocalesData[initialLocale]?.title || '',
|
|
246
|
+
body: allLocalesData[initialLocale]?.body || '',
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
setEditingContentId(content.content_id || content.id!);
|
|
250
|
+
setIsFormDialogOpen(true);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
console.error('Error loading content:', error);
|
|
253
|
+
toast.error(t('errorLoad'));
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const onSubmit = async (values: z.infer<typeof contentSchema>) => {
|
|
258
|
+
setIsSubmitting(true);
|
|
259
|
+
try {
|
|
260
|
+
const payload = {
|
|
261
|
+
slug: values.slug,
|
|
262
|
+
status: values.status,
|
|
263
|
+
locale: {
|
|
264
|
+
[selectedLocale]: {
|
|
265
|
+
title: values.title,
|
|
266
|
+
body: values.body,
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
if (editingContentId) {
|
|
272
|
+
await request({
|
|
273
|
+
url: `/content/${editingContentId}`,
|
|
274
|
+
method: 'PATCH',
|
|
275
|
+
data: payload,
|
|
276
|
+
});
|
|
277
|
+
toast.success(t('successUpdate'));
|
|
278
|
+
} else {
|
|
279
|
+
await request({
|
|
280
|
+
url: '/content',
|
|
281
|
+
method: 'POST',
|
|
282
|
+
data: payload,
|
|
283
|
+
});
|
|
284
|
+
toast.success(t('successCreate'));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
setIsFormDialogOpen(false);
|
|
288
|
+
refetch();
|
|
289
|
+
refetchStats();
|
|
290
|
+
} catch (error: any) {
|
|
291
|
+
toast.error(
|
|
292
|
+
error?.message || t(editingContentId ? 'errorUpdate' : 'errorCreate')
|
|
293
|
+
);
|
|
294
|
+
} finally {
|
|
295
|
+
setIsSubmitting(false);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const handleDeleteContent = (contentId: number): void => {
|
|
300
|
+
request({
|
|
301
|
+
url: `/content/${contentId}`,
|
|
302
|
+
method: 'DELETE',
|
|
303
|
+
}).then(() => {
|
|
304
|
+
toast.success(t('successDelete'));
|
|
305
|
+
refetch();
|
|
306
|
+
refetchStats();
|
|
307
|
+
});
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const handleSearchChange = (value: string): void => {
|
|
311
|
+
setSearchTerm(value);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
refetch();
|
|
316
|
+
}, [debouncedSearch]);
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<div className="flex flex-col h-screen px-4">
|
|
320
|
+
<PageHeader
|
|
321
|
+
breadcrumbs={[
|
|
322
|
+
{ label: 'Home', href: '/' },
|
|
323
|
+
{ label: t('description') },
|
|
324
|
+
]}
|
|
325
|
+
title={t('title')}
|
|
326
|
+
description={t('description')}
|
|
327
|
+
actions={[
|
|
328
|
+
{
|
|
329
|
+
label: t('buttonNew'),
|
|
330
|
+
onClick: () => handleNewContent(),
|
|
331
|
+
variant: 'default',
|
|
332
|
+
},
|
|
333
|
+
]}
|
|
334
|
+
/>
|
|
335
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
|
336
|
+
<Card className="transition-shadow hover:shadow-md">
|
|
337
|
+
<CardContent className="p-6">
|
|
338
|
+
<div className="flex items-center space-x-3">
|
|
339
|
+
<FileText className="h-10 w-10 text-blue-500" />
|
|
340
|
+
<div>
|
|
341
|
+
<p className="text-sm font-medium text-muted-foreground">
|
|
342
|
+
{t('statsTotalPages')}
|
|
343
|
+
</p>
|
|
344
|
+
<p className="text-2xl font-bold">{stats?.total}</p>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
</CardContent>
|
|
348
|
+
</Card>
|
|
349
|
+
|
|
350
|
+
<Card className="transition-shadow hover:shadow-md">
|
|
351
|
+
<CardContent className="p-6">
|
|
352
|
+
<div className="flex items-center space-x-3">
|
|
353
|
+
<Eye className="h-10 w-10 text-green-500" />
|
|
354
|
+
<div>
|
|
355
|
+
<p className="text-sm font-medium text-muted-foreground">
|
|
356
|
+
{t('statsPublished')}
|
|
357
|
+
</p>
|
|
358
|
+
<p className="text-2xl font-bold">{stats?.totalPublished}</p>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
</CardContent>
|
|
362
|
+
</Card>
|
|
363
|
+
|
|
364
|
+
<Card className="transition-shadow hover:shadow-md">
|
|
365
|
+
<CardContent className="p-6">
|
|
366
|
+
<div className="flex items-center space-x-3">
|
|
367
|
+
<Edit className="h-10 w-10 text-orange-500" />
|
|
368
|
+
<div>
|
|
369
|
+
<p className="text-sm font-medium text-muted-foreground">
|
|
370
|
+
{t('statsDrafts')}
|
|
371
|
+
</p>
|
|
372
|
+
<p className="text-2xl font-bold">{stats?.totalDraft}</p>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
</CardContent>
|
|
376
|
+
</Card>
|
|
377
|
+
|
|
378
|
+
<Card className="transition-shadow hover:shadow-md">
|
|
379
|
+
<CardContent className="p-6">
|
|
380
|
+
<div className="flex items-center space-x-3">
|
|
381
|
+
<Languages className="h-10 w-10 text-purple-500" />
|
|
382
|
+
<div>
|
|
383
|
+
<p className="text-sm font-medium text-muted-foreground">
|
|
384
|
+
{t('statsActiveLanguages')}
|
|
385
|
+
</p>
|
|
386
|
+
<p className="text-2xl font-bold">
|
|
387
|
+
{stats?.totalEnabledLanguages}
|
|
388
|
+
</p>
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
</CardContent>
|
|
392
|
+
</Card>
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
<div className="relative my-4">
|
|
396
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
397
|
+
<Input
|
|
398
|
+
placeholder={t('searchPlaceholder')}
|
|
399
|
+
value={searchTerm}
|
|
400
|
+
onChange={(e) => handleSearchChange(e.target.value)}
|
|
401
|
+
className="pl-10"
|
|
402
|
+
/>
|
|
403
|
+
</div>
|
|
404
|
+
|
|
405
|
+
<div className="space-y-4">
|
|
406
|
+
{contents.data.length > 0 ? (
|
|
407
|
+
<div className="grid gap-4">
|
|
408
|
+
{contents.data.map((content: Content) => (
|
|
409
|
+
<Card
|
|
410
|
+
key={content.id}
|
|
411
|
+
className="overflow-hidden transition-all duration-200 hover:border-primary/20 hover:shadow-md"
|
|
412
|
+
>
|
|
413
|
+
<CardContent className="p-6">
|
|
414
|
+
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
415
|
+
<div className="flex-1 space-y-3">
|
|
416
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
417
|
+
<div className="space-y-1">
|
|
418
|
+
<div className="flex items-center space-x-2">
|
|
419
|
+
<h3 className="text-lg font-semibold">
|
|
420
|
+
/{content.slug}
|
|
421
|
+
</h3>
|
|
422
|
+
{content.status && (
|
|
423
|
+
<Badge
|
|
424
|
+
className={cn(
|
|
425
|
+
'text-white',
|
|
426
|
+
getStatusBadge(content.status)?.color ||
|
|
427
|
+
'bg-gray-500'
|
|
428
|
+
)}
|
|
429
|
+
>
|
|
430
|
+
{getStatusBadge(content.status)?.label || 'N/A'}
|
|
431
|
+
</Badge>
|
|
432
|
+
)}
|
|
433
|
+
</div>
|
|
434
|
+
<div className="flex items-center space-x-2">
|
|
435
|
+
{content.available_locales?.length &&
|
|
436
|
+
content.available_locales.map((al) => (
|
|
437
|
+
<Badge
|
|
438
|
+
variant="outline"
|
|
439
|
+
className="text-xs"
|
|
440
|
+
key={al.id}
|
|
441
|
+
>
|
|
442
|
+
<Globe className="mr-1 h-3 w-3" />
|
|
443
|
+
{al.code.toUpperCase()}
|
|
444
|
+
</Badge>
|
|
445
|
+
))}
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
<div className="space-y-2">
|
|
451
|
+
<div className="flex items-center space-x-4 text-xs text-muted-foreground">
|
|
452
|
+
<div className="flex items-center space-x-1">
|
|
453
|
+
<Calendar className="h-3 w-3" />
|
|
454
|
+
<span>
|
|
455
|
+
{t('created')}{' '}
|
|
456
|
+
{content.created_at
|
|
457
|
+
? formatDateTime(
|
|
458
|
+
content.created_at,
|
|
459
|
+
getSettingValue,
|
|
460
|
+
currentLocaleCode
|
|
461
|
+
)
|
|
462
|
+
: 'N/A'}
|
|
463
|
+
</span>
|
|
464
|
+
</div>
|
|
465
|
+
<div className="flex items-center space-x-1">
|
|
466
|
+
<Calendar className="h-3 w-3" />
|
|
467
|
+
<span>
|
|
468
|
+
{t('updated')}{' '}
|
|
469
|
+
{content.updated_at
|
|
470
|
+
? formatDateTime(
|
|
471
|
+
content.updated_at,
|
|
472
|
+
getSettingValue,
|
|
473
|
+
currentLocaleCode
|
|
474
|
+
)
|
|
475
|
+
: 'N/A'}
|
|
476
|
+
</span>
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
|
|
482
|
+
<div className="flex flex-wrap gap-2 lg:flex-col lg:items-end">
|
|
483
|
+
<Button
|
|
484
|
+
variant="outline"
|
|
485
|
+
size="sm"
|
|
486
|
+
onClick={() => handleEditContent(content)}
|
|
487
|
+
className="transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-950"
|
|
488
|
+
>
|
|
489
|
+
<Edit className="mr-1 h-4 w-4" />
|
|
490
|
+
{t('buttonEdit')}
|
|
491
|
+
</Button>
|
|
492
|
+
|
|
493
|
+
<AlertDialog>
|
|
494
|
+
<AlertDialogTrigger asChild>
|
|
495
|
+
<Button
|
|
496
|
+
variant="outline"
|
|
497
|
+
size="sm"
|
|
498
|
+
className="bg-transparent transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950"
|
|
499
|
+
>
|
|
500
|
+
<Trash2 className="mr-1 h-4 w-4" />
|
|
501
|
+
{t('buttonDelete')}
|
|
502
|
+
</Button>
|
|
503
|
+
</AlertDialogTrigger>
|
|
504
|
+
<AlertDialogContent>
|
|
505
|
+
<AlertDialogHeader>
|
|
506
|
+
<AlertDialogTitle>
|
|
507
|
+
{t('alertDeleteTitle')}
|
|
508
|
+
</AlertDialogTitle>
|
|
509
|
+
<AlertDialogDescription>
|
|
510
|
+
{t('alertDeleteDescription', {
|
|
511
|
+
slug: content.slug,
|
|
512
|
+
})}
|
|
513
|
+
</AlertDialogDescription>
|
|
514
|
+
</AlertDialogHeader>
|
|
515
|
+
<AlertDialogFooter>
|
|
516
|
+
<AlertDialogCancel>
|
|
517
|
+
{t('alertDeleteCancel')}
|
|
518
|
+
</AlertDialogCancel>
|
|
519
|
+
<AlertDialogAction
|
|
520
|
+
onClick={() => {
|
|
521
|
+
if (content.id) {
|
|
522
|
+
handleDeleteContent(
|
|
523
|
+
content.content_id || content.id
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
}}
|
|
527
|
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
528
|
+
>
|
|
529
|
+
{t('alertDeleteConfirm')}
|
|
530
|
+
</AlertDialogAction>
|
|
531
|
+
</AlertDialogFooter>
|
|
532
|
+
</AlertDialogContent>
|
|
533
|
+
</AlertDialog>
|
|
534
|
+
</div>
|
|
535
|
+
</div>
|
|
536
|
+
</CardContent>
|
|
537
|
+
</Card>
|
|
538
|
+
))}
|
|
539
|
+
</div>
|
|
540
|
+
) : (
|
|
541
|
+
<Card>
|
|
542
|
+
<CardContent className="p-12 text-center">
|
|
543
|
+
<div className="flex flex-col items-center space-y-4">
|
|
544
|
+
<FileText className="h-12 w-12 text-muted-foreground" />
|
|
545
|
+
<div>
|
|
546
|
+
<h3 className="text-lg font-semibold">{t('emptyTitle')}</h3>
|
|
547
|
+
<p className="text-muted-foreground">
|
|
548
|
+
{t('emptyDescription')}
|
|
549
|
+
</p>
|
|
550
|
+
</div>
|
|
551
|
+
<Button onClick={handleNewContent}>
|
|
552
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
553
|
+
{t('emptyButtonCreate')}
|
|
554
|
+
</Button>
|
|
555
|
+
</div>
|
|
556
|
+
</CardContent>
|
|
557
|
+
</Card>
|
|
558
|
+
)}
|
|
559
|
+
</div>
|
|
560
|
+
<Dialog open={isFormDialogOpen} onOpenChange={setIsFormDialogOpen}>
|
|
561
|
+
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
|
562
|
+
<DialogHeader>
|
|
563
|
+
<DialogTitle>
|
|
564
|
+
{editingContentId
|
|
565
|
+
? t('dialogFormTitleEdit')
|
|
566
|
+
: t('dialogFormTitleCreate')}
|
|
567
|
+
</DialogTitle>
|
|
568
|
+
<DialogDescription>
|
|
569
|
+
{editingContentId
|
|
570
|
+
? t('dialogFormDescriptionEdit')
|
|
571
|
+
: t('dialogFormDescriptionCreate')}
|
|
572
|
+
</DialogDescription>
|
|
573
|
+
</DialogHeader>
|
|
574
|
+
|
|
575
|
+
<Form {...form}>
|
|
576
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
|
577
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
578
|
+
<FormField
|
|
579
|
+
control={form.control}
|
|
580
|
+
name="slug"
|
|
581
|
+
render={({ field }) => (
|
|
582
|
+
<FormItem>
|
|
583
|
+
<FormLabel>{t('formLabelSlug')}</FormLabel>
|
|
584
|
+
<FormControl>
|
|
585
|
+
<Input
|
|
586
|
+
placeholder={t('formPlaceholderSlug')}
|
|
587
|
+
{...field}
|
|
588
|
+
disabled={isSubmitting}
|
|
589
|
+
/>
|
|
590
|
+
</FormControl>
|
|
591
|
+
<FormMessage />
|
|
592
|
+
</FormItem>
|
|
593
|
+
)}
|
|
594
|
+
/>
|
|
595
|
+
|
|
596
|
+
<FormField
|
|
597
|
+
control={form.control}
|
|
598
|
+
name="status"
|
|
599
|
+
render={({ field }) => (
|
|
600
|
+
<FormItem>
|
|
601
|
+
<FormLabel>{t('formLabelStatus')}</FormLabel>
|
|
602
|
+
<Select
|
|
603
|
+
onValueChange={field.onChange}
|
|
604
|
+
value={field.value}
|
|
605
|
+
disabled={isSubmitting}
|
|
606
|
+
>
|
|
607
|
+
<FormControl>
|
|
608
|
+
<SelectTrigger className="w-full">
|
|
609
|
+
<SelectValue
|
|
610
|
+
placeholder={t('formPlaceholderStatus')}
|
|
611
|
+
/>
|
|
612
|
+
</SelectTrigger>
|
|
613
|
+
</FormControl>
|
|
614
|
+
<SelectContent>
|
|
615
|
+
{statusOptions.map((option) => (
|
|
616
|
+
<SelectItem key={option.value} value={option.value}>
|
|
617
|
+
{option.label}
|
|
618
|
+
</SelectItem>
|
|
619
|
+
))}
|
|
620
|
+
</SelectContent>
|
|
621
|
+
</Select>
|
|
622
|
+
<FormMessage />
|
|
623
|
+
</FormItem>
|
|
624
|
+
)}
|
|
625
|
+
/>
|
|
626
|
+
</div>
|
|
627
|
+
|
|
628
|
+
{editingContentId && (
|
|
629
|
+
<div className="space-y-2">
|
|
630
|
+
<Label>{t('formLabelLanguage')}</Label>
|
|
631
|
+
<Select
|
|
632
|
+
value={selectedLocale}
|
|
633
|
+
onValueChange={(newLocale) => {
|
|
634
|
+
const currentValues = form.getValues();
|
|
635
|
+
const localesData = (form as any).localesData || {};
|
|
636
|
+
|
|
637
|
+
if (selectedLocale) {
|
|
638
|
+
localesData[selectedLocale] = {
|
|
639
|
+
title: currentValues.title,
|
|
640
|
+
body: currentValues.body,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const newLocaleData = localesData[newLocale] || {
|
|
645
|
+
title: '',
|
|
646
|
+
body: '',
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
form.setValue('title', newLocaleData.title);
|
|
650
|
+
form.setValue('body', newLocaleData.body);
|
|
651
|
+
(form as any).localesData = localesData;
|
|
652
|
+
setSelectedLocale(newLocale);
|
|
653
|
+
}}
|
|
654
|
+
disabled={isSubmitting}
|
|
655
|
+
>
|
|
656
|
+
<SelectTrigger className="w-full">
|
|
657
|
+
<SelectValue placeholder={t('formPlaceholderLanguage')} />
|
|
658
|
+
</SelectTrigger>
|
|
659
|
+
<SelectContent>
|
|
660
|
+
{locales.map((locale) => (
|
|
661
|
+
<SelectItem key={locale.code} value={locale.code}>
|
|
662
|
+
<div className="flex items-center">
|
|
663
|
+
<Globe className="mr-2 h-4 w-4" />
|
|
664
|
+
{locale.name}
|
|
665
|
+
</div>
|
|
666
|
+
</SelectItem>
|
|
667
|
+
))}
|
|
668
|
+
</SelectContent>
|
|
669
|
+
</Select>
|
|
670
|
+
</div>
|
|
671
|
+
)}
|
|
672
|
+
|
|
673
|
+
<div className="space-y-4">
|
|
674
|
+
<FormField
|
|
675
|
+
control={form.control}
|
|
676
|
+
name="title"
|
|
677
|
+
render={({ field }) => (
|
|
678
|
+
<FormItem>
|
|
679
|
+
<FormLabel>
|
|
680
|
+
{t('formLabelTitle', {
|
|
681
|
+
language:
|
|
682
|
+
locales.find((l) => l.code === selectedLocale)
|
|
683
|
+
?.name || '',
|
|
684
|
+
})}
|
|
685
|
+
</FormLabel>
|
|
686
|
+
<FormControl>
|
|
687
|
+
<Input
|
|
688
|
+
placeholder={t('formPlaceholderTitle', {
|
|
689
|
+
language:
|
|
690
|
+
locales.find((l) => l.code === selectedLocale)
|
|
691
|
+
?.name || '',
|
|
692
|
+
})}
|
|
693
|
+
{...field}
|
|
694
|
+
disabled={isSubmitting}
|
|
695
|
+
/>
|
|
696
|
+
</FormControl>
|
|
697
|
+
<FormMessage />
|
|
698
|
+
</FormItem>
|
|
699
|
+
)}
|
|
700
|
+
/>
|
|
701
|
+
|
|
702
|
+
<FormField
|
|
703
|
+
control={form.control}
|
|
704
|
+
name="body"
|
|
705
|
+
render={({ field }) => (
|
|
706
|
+
<FormItem>
|
|
707
|
+
<FormLabel>
|
|
708
|
+
{t('formLabelContent', {
|
|
709
|
+
language:
|
|
710
|
+
locales.find((l) => l.code === selectedLocale)
|
|
711
|
+
?.name || '',
|
|
712
|
+
})}
|
|
713
|
+
</FormLabel>
|
|
714
|
+
<FormControl>
|
|
715
|
+
<RichTextEditor className="max-w-[845px]" {...field} />
|
|
716
|
+
</FormControl>
|
|
717
|
+
<FormMessage />
|
|
718
|
+
</FormItem>
|
|
719
|
+
)}
|
|
720
|
+
/>
|
|
721
|
+
</div>
|
|
722
|
+
|
|
723
|
+
<DialogFooter>
|
|
724
|
+
<Button
|
|
725
|
+
type="button"
|
|
726
|
+
variant="outline"
|
|
727
|
+
onClick={() => setIsFormDialogOpen(false)}
|
|
728
|
+
disabled={isSubmitting}
|
|
729
|
+
>
|
|
730
|
+
{t('formButtonCancel')}
|
|
731
|
+
</Button>
|
|
732
|
+
<Button type="submit" disabled={isSubmitting}>
|
|
733
|
+
{isSubmitting && (
|
|
734
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
735
|
+
)}
|
|
736
|
+
{editingContentId
|
|
737
|
+
? t('formButtonUpdate')
|
|
738
|
+
: t('formButtonCreate')}
|
|
739
|
+
</Button>
|
|
740
|
+
</DialogFooter>
|
|
741
|
+
</form>
|
|
742
|
+
</Form>
|
|
743
|
+
</DialogContent>
|
|
744
|
+
</Dialog>
|
|
745
|
+
</div>
|
|
746
|
+
);
|
|
747
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ContentPage": {
|
|
3
|
+
"title": "Contents",
|
|
4
|
+
"description": "Manage website content in multiple languages",
|
|
5
|
+
"buttonNew": "New Content",
|
|
6
|
+
"statsTotalPages": "Total Pages",
|
|
7
|
+
"statsPublished": "Published",
|
|
8
|
+
"statsDrafts": "Drafts",
|
|
9
|
+
"statsActiveLanguages": "Active Languages",
|
|
10
|
+
"searchPlaceholder": "Search by slug, title or content...",
|
|
11
|
+
"filterAllStatus": "All Statuses",
|
|
12
|
+
"statusDraft": "Draft",
|
|
13
|
+
"statusPublished": "Published",
|
|
14
|
+
"buttonView": "View",
|
|
15
|
+
"buttonEdit": "Edit",
|
|
16
|
+
"buttonDelete": "Delete",
|
|
17
|
+
"alertDeleteTitle": "Confirm Deletion",
|
|
18
|
+
"alertDeleteDescription": "Are you sure you want to delete the page \"/{slug}\"? This action cannot be undone.",
|
|
19
|
+
"alertDeleteCancel": "Cancel",
|
|
20
|
+
"alertDeleteConfirm": "Delete",
|
|
21
|
+
"emptyTitle": "No content found",
|
|
22
|
+
"emptyDescription": "Try adjusting the filters or create new content.",
|
|
23
|
+
"emptyButtonCreate": "Create First Content",
|
|
24
|
+
"dialogViewTitle": "View Content: /{slug}",
|
|
25
|
+
"dialogViewDescription": "View content in all available languages",
|
|
26
|
+
"dialogViewLabelTitle": "Title",
|
|
27
|
+
"dialogViewNoTitle": "No title",
|
|
28
|
+
"dialogViewLabelContent": "Content",
|
|
29
|
+
"dialogViewNoContent": "No content",
|
|
30
|
+
"dialogViewButtonClose": "Close",
|
|
31
|
+
"dialogViewButtonEdit": "Edit",
|
|
32
|
+
"dialogFormTitleCreate": "Create New Content",
|
|
33
|
+
"dialogFormTitleEdit": "Edit Content",
|
|
34
|
+
"dialogFormDescriptionCreate": "Fill in the details of the new content",
|
|
35
|
+
"dialogFormDescriptionEdit": "Update content details",
|
|
36
|
+
"formLabelSlug": "Slug",
|
|
37
|
+
"formPlaceholderSlug": "example-page",
|
|
38
|
+
"formLabelStatus": "Status",
|
|
39
|
+
"formPlaceholderStatus": "Select status",
|
|
40
|
+
"formLabelLanguage": "Language",
|
|
41
|
+
"formPlaceholderLanguage": "Select language",
|
|
42
|
+
"formLabelContentByLanguage": "Content by Language",
|
|
43
|
+
"formLabelTitle": "Title ({language})",
|
|
44
|
+
"formPlaceholderTitle": "Enter title in {language}",
|
|
45
|
+
"formLabelContent": "Content ({language})",
|
|
46
|
+
"formPlaceholderContent": "Enter content in {language}",
|
|
47
|
+
"formButtonCancel": "Cancel",
|
|
48
|
+
"formButtonCreate": "Create",
|
|
49
|
+
"formButtonUpdate": "Update",
|
|
50
|
+
"errorSlugRequired": "Slug is required",
|
|
51
|
+
"errorTitleRequired": "Title is required",
|
|
52
|
+
"errorContentRequired": "Content is required",
|
|
53
|
+
"successCreate": "Content created successfully",
|
|
54
|
+
"successUpdate": "Content updated successfully",
|
|
55
|
+
"successDelete": "Content deleted successfully",
|
|
56
|
+
"errorCreate": "Error creating content",
|
|
57
|
+
"errorUpdate": "Error updating content",
|
|
58
|
+
"errorLoad": "Error loading content",
|
|
59
|
+
"created": "Created:",
|
|
60
|
+
"updated": "Updated:"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ContentPage": {
|
|
3
|
+
"title": "Conteúdos",
|
|
4
|
+
"description": "Gerencie o conteúdo das páginas do site em múltiplos idiomas",
|
|
5
|
+
"buttonNew": "Novo Conteúdo",
|
|
6
|
+
"statsTotalPages": "Total de Páginas",
|
|
7
|
+
"statsPublished": "Publicadas",
|
|
8
|
+
"statsDrafts": "Rascunhos",
|
|
9
|
+
"statsActiveLanguages": "Idiomas Ativos",
|
|
10
|
+
"searchPlaceholder": "Buscar por slug, título ou conteúdo...",
|
|
11
|
+
"filterAllStatus": "Todos os Status",
|
|
12
|
+
"statusDraft": "Rascunho",
|
|
13
|
+
"statusPublished": "Publicado",
|
|
14
|
+
"buttonView": "Ver",
|
|
15
|
+
"buttonEdit": "Editar",
|
|
16
|
+
"buttonDelete": "Excluir",
|
|
17
|
+
"alertDeleteTitle": "Confirmar Exclusão",
|
|
18
|
+
"alertDeleteDescription": "Tem certeza que deseja excluir a página \"/{slug}\"? Esta ação não pode ser desfeita.",
|
|
19
|
+
"alertDeleteCancel": "Cancelar",
|
|
20
|
+
"alertDeleteConfirm": "Excluir",
|
|
21
|
+
"emptyTitle": "Nenhum conteúdo encontrado",
|
|
22
|
+
"emptyDescription": "Tente ajustar os filtros ou criar um novo conteúdo.",
|
|
23
|
+
"emptyButtonCreate": "Criar Primeiro Conteúdo",
|
|
24
|
+
"dialogViewTitle": "Visualizar Conteúdo: /{slug}",
|
|
25
|
+
"dialogViewDescription": "Visualize o conteúdo em todos os idiomas disponíveis",
|
|
26
|
+
"dialogViewLabelTitle": "Título",
|
|
27
|
+
"dialogViewNoTitle": "Sem título",
|
|
28
|
+
"dialogViewLabelContent": "Conteúdo",
|
|
29
|
+
"dialogViewNoContent": "Sem conteúdo",
|
|
30
|
+
"dialogViewButtonClose": "Fechar",
|
|
31
|
+
"dialogViewButtonEdit": "Editar",
|
|
32
|
+
"dialogFormTitleCreate": "Criar Novo Conteúdo",
|
|
33
|
+
"dialogFormTitleEdit": "Editar Conteúdo",
|
|
34
|
+
"dialogFormDescriptionCreate": "Preencha os detalhes do novo conteúdo",
|
|
35
|
+
"dialogFormDescriptionEdit": "Atualize os detalhes do conteúdo",
|
|
36
|
+
"formLabelSlug": "Slug",
|
|
37
|
+
"formPlaceholderSlug": "pagina-exemplo",
|
|
38
|
+
"formLabelStatus": "Status",
|
|
39
|
+
"formPlaceholderStatus": "Selecione o status",
|
|
40
|
+
"formLabelLanguage": "Idioma",
|
|
41
|
+
"formPlaceholderLanguage": "Selecione o idioma",
|
|
42
|
+
"formLabelContentByLanguage": "Conteúdo por Idioma",
|
|
43
|
+
"formLabelTitle": "Título ({language})",
|
|
44
|
+
"formPlaceholderTitle": "Digite o título em {language}",
|
|
45
|
+
"formLabelContent": "Conteúdo ({language})",
|
|
46
|
+
"formPlaceholderContent": "Digite o conteúdo em {language}",
|
|
47
|
+
"formButtonCancel": "Cancelar",
|
|
48
|
+
"formButtonCreate": "Criar",
|
|
49
|
+
"formButtonUpdate": "Atualizar",
|
|
50
|
+
"errorSlugRequired": "Slug é obrigatório",
|
|
51
|
+
"errorTitleRequired": "Título é obrigatório",
|
|
52
|
+
"errorContentRequired": "Conteúdo é obrigatório",
|
|
53
|
+
"successCreate": "Conteúdo criado com sucesso",
|
|
54
|
+
"successUpdate": "Conteúdo atualizado com sucesso",
|
|
55
|
+
"successDelete": "Conteúdo excluído com sucesso",
|
|
56
|
+
"errorCreate": "Erro ao criar conteúdo",
|
|
57
|
+
"errorUpdate": "Erro ao atualizar conteúdo",
|
|
58
|
+
"errorLoad": "Erro ao carregar conteúdo",
|
|
59
|
+
"created": "Criado:",
|
|
60
|
+
"updated": "Atualizado:"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hed-hog/content",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.190",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"dependencies": {
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
"@nestjs/mapped-types": "*",
|
|
12
12
|
"@hed-hog/api-prisma": "0.0.4",
|
|
13
13
|
"@hed-hog/api-pagination": "0.0.5",
|
|
14
|
-
"@hed-hog/
|
|
15
|
-
"@hed-hog/
|
|
16
|
-
"@hed-hog/api": "0.0.
|
|
14
|
+
"@hed-hog/core": "0.0.190",
|
|
15
|
+
"@hed-hog/api": "0.0.3",
|
|
16
|
+
"@hed-hog/api-locale": "0.0.11"
|
|
17
17
|
},
|
|
18
18
|
"exports": {
|
|
19
19
|
".": {
|