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:
@@ -1,14 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowRight, Loader2, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Loader2, AlertCircle, ChevronDown, Check } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Language } from './types';
|
||||
|
||||
interface LanguageSelectorProps {
|
||||
@@ -21,84 +16,179 @@ interface LanguageSelectorProps {
|
||||
onTargetChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function LanguageSelector({
|
||||
sourceLang,
|
||||
targetLang,
|
||||
languages,
|
||||
isLoading,
|
||||
error,
|
||||
onSourceChange,
|
||||
onTargetChange,
|
||||
/* ── Combobox dropdown with search ──────────────────────────────── */
|
||||
function Combobox({
|
||||
value,
|
||||
options,
|
||||
includeAuto,
|
||||
autoLabel,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
options: Language[];
|
||||
includeAuto: boolean;
|
||||
autoLabel: string;
|
||||
placeholder: string;
|
||||
onChange: (code: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
if (open) document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const allOptions = includeAuto
|
||||
? [{ code: 'auto', name: autoLabel }, ...options]
|
||||
: options;
|
||||
|
||||
const q = query.toLowerCase().trim();
|
||||
const filtered = q
|
||||
? allOptions.filter(l => l.name.toLowerCase().includes(q) || l.code.toLowerCase().includes(q))
|
||||
: allOptions;
|
||||
|
||||
const label = value === 'auto' ? autoLabel : allOptions.find(l => l.code === value)?.name ?? value;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-lg border px-3 py-2 text-sm transition-colors',
|
||||
open
|
||||
? 'border-primary ring-2 ring-primary/15 outline-none'
|
||||
: 'border-border bg-background hover:border-muted-foreground/40'
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-foreground">{label || placeholder}</span>
|
||||
<ChevronDown className={cn('size-4 shrink-0 text-muted-foreground transition-transform ms-2', open && 'rotate-180')} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 overflow-hidden rounded-lg border border-border bg-popover shadow-md">
|
||||
<div className="border-b border-border px-2 py-1.5">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full bg-transparent px-1 py-1 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[200px] overflow-y-auto p-1">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-3 text-center text-xs text-muted-foreground">No results</div>
|
||||
)}
|
||||
{filtered.map(lang => (
|
||||
<button
|
||||
key={lang.code}
|
||||
type="button"
|
||||
onClick={() => { onChange(lang.code); setOpen(false); setQuery(''); }}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors',
|
||||
value === lang.code
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<span className="flex-1 text-start">{lang.name}</span>
|
||||
{value === lang.code && <Check className="size-3.5 shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main component ─────────────────────────────────────────────── */
|
||||
export default function LanguageSelector({
|
||||
sourceLang, targetLang, languages, isLoading, error,
|
||||
onSourceChange, onTargetChange,
|
||||
}: LanguageSelectorProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertCircle className="size-3.5 shrink-0" />
|
||||
<span>{t('dashboard.translate.language.loadErrorPrefix')} {error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-2 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span className="text-xs">{t('dashboard.translate.language.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canSwap = sourceLang !== 'auto';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertCircle className="size-3.5 shrink-0" />
|
||||
<span>{t('dashboard.translate.language.loadErrorPrefix')} {error}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{/* Source */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('dashboard.translate.language.source')}
|
||||
</label>
|
||||
<Combobox
|
||||
value={sourceLang}
|
||||
options={languages}
|
||||
includeAuto
|
||||
autoLabel={t('dashboard.translate.language.autoDetect')}
|
||||
placeholder={t('dashboard.translate.language.selectPlaceholder')}
|
||||
onChange={onSourceChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-3">
|
||||
{/* Source language */}
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{t('dashboard.translate.language.source')}
|
||||
</label>
|
||||
<Select value={sourceLang} onValueChange={onSourceChange} disabled={isLoading}>
|
||||
<SelectTrigger className="h-11 w-full">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>{t('dashboard.translate.language.loading')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t('dashboard.translate.language.autoDetect')} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t('dashboard.translate.language.autoDetect')}</SelectItem>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Swap */}
|
||||
<div className="flex justify-center -my-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canSwap && (() => { const s = sourceLang; onSourceChange(targetLang); onTargetChange(s); })()}
|
||||
disabled={!canSwap}
|
||||
className={cn(
|
||||
'flex size-8 items-center justify-center rounded-full border shadow-sm transition-colors',
|
||||
canSwap
|
||||
? 'border-border bg-background text-muted-foreground hover:border-primary hover:text-primary'
|
||||
: 'cursor-not-allowed border-border/50 bg-muted text-muted-foreground/40'
|
||||
)}
|
||||
title="Inverser"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Arrow — flips in RTL via rtl: variant */}
|
||||
<div className="mb-2 flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<ArrowRight className="size-4 rtl:rotate-180" />
|
||||
</div>
|
||||
|
||||
{/* Target language */}
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{t('dashboard.translate.language.target')}
|
||||
</label>
|
||||
<Select value={targetLang} onValueChange={onTargetChange} disabled={isLoading}>
|
||||
<SelectTrigger className="h-11 w-full">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>{t('dashboard.translate.language.loading')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t('dashboard.translate.language.selectPlaceholder')} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Target */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('dashboard.translate.language.target')}
|
||||
</label>
|
||||
<Combobox
|
||||
value={targetLang}
|
||||
options={languages}
|
||||
includeAuto={false}
|
||||
autoLabel=""
|
||||
placeholder={t('dashboard.translate.language.selectPlaceholder')}
|
||||
onChange={onTargetChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user