feat: homelab deployment - NPM + IONOS DNS + monitoring + NAS backup

- Restructured docker-compose for Nginx Proxy Manager (no custom nginx)
- Added domain wordly.art configuration
- Added Prometheus + Grafana monitoring stack with pre-configured dashboards
- Added PostgreSQL backup script to NAS (daily/weekly/monthly rotation)
- Added alert rules for backend, system, and Docker metrics
- Updated deployment guide for NPM + IONOS DNS homelab setup
- Added marketing plan document
- PDF translator and watermark support
- Enhanced middleware, routes, and translator modules

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 11:43:28 +02:00
parent 16ac7ca2b9
commit ce8e150a61
110 changed files with 6935 additions and 4301 deletions

View File

@@ -95,7 +95,7 @@ function TemplateCard({
onClick={() => onSelect(template)}
disabled={isLoading}
className={cn(
'flex flex-col gap-2 rounded-lg border p-3 text-left transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
'flex flex-col gap-2 rounded-lg border p-3 text-start transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
colorClass
)}
>
@@ -477,7 +477,7 @@ export function CreateGlossaryDialog({
{t('glossaries.dialog.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{isProcessing && <Loader2 className="size-3.5 animate-spin mr-1.5" />}
{isProcessing && <Loader2 className="size-3.5 animate-spin me-1.5" />}
{submitLabel}
</Button>
</DialogFooter>

View File

@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { BookText, Pencil, Trash2 } from 'lucide-react';
import type { GlossaryListItem } from './types';
import { useI18n } from '@/lib/i18n';
import { useI18n, formatDate } from '@/lib/i18n';
interface GlossaryCardProps {
glossary: GlossaryListItem;
@@ -30,7 +30,7 @@ export const GlossaryCard = memo(function GlossaryCard({
onDelete(glossary.id, glossary.name);
}, [glossary.id, glossary.name, onDelete]);
const formattedDate = new Date(glossary.created_at).toLocaleDateString(locale === 'fr' ? 'fr-FR' : 'en-US', {
const formattedDate = formatDate(new Date(glossary.created_at), locale, {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -60,8 +60,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
} = useQuery<GlossaryListResponse, ApiClientError>({
queryKey: [...GLOSSARIES_QUERY_KEY, page, perPage],
queryFn: async () => {
const response = await apiClient.get<GlossaryListResponse>(`/api/v1/glossaries?page=${page}&per_page=${perPage}`);
return response.data;
return apiClient.get<GlossaryListResponse>(`/api/v1/glossaries?page=${page}&per_page=${perPage}`);
},
retry: (failureCount, err) => {
if (err.status === 403 || err.status === 401) return false;
@@ -80,7 +79,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
const createMutation = useMutation({
mutationFn: async (input: GlossaryCreateInput): Promise<Glossary> => {
const response = await apiClient.post<GlossaryDetailResponse>('/api/v1/glossaries', input);
return response.data.data;
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
@@ -90,7 +89,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: GlossaryUpdateInput }): Promise<Glossary> => {
const response = await apiClient.patch<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`, data);
return response.data.data;
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
@@ -125,7 +124,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
const response = await apiClient.post<GlossaryDetailResponse>(
`/api/v1/glossaries/import?${params.toString()}`
);
return response.data.data;
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
@@ -191,8 +190,7 @@ export function useGlossaryTemplates() {
const { data, isLoading, error } = useQuery<GlossaryTemplatesResponse, ApiClientError>({
queryKey: ['glossary-templates'],
queryFn: async () => {
const response = await apiClient.get<GlossaryTemplatesResponse>('/api/v1/glossaries/templates/list');
return response.data;
return apiClient.get<GlossaryTemplatesResponse>('/api/v1/glossaries/templates/list');
},
staleTime: 5 * 60 * 1000, // templates rarely change
retry: 1,
@@ -214,8 +212,7 @@ export function useGlossary(id: string | null) {
queryKey: [...GLOSSARIES_QUERY_KEY, id],
queryFn: async () => {
if (!id) throw new Error('Glossary ID is required');
const response = await apiClient.get<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`);
return response.data;
return apiClient.get<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`);
},
enabled: !!id,
retry: (failureCount, err) => {