|
@@ -1,7 +1,7 @@
|
|
|
'use client'
|
|
'use client'
|
|
|
|
|
|
|
|
import { useRef, useState } from 'react'
|
|
import { useRef, useState } from 'react'
|
|
|
-import { useTranslations } from 'next-intl'
|
|
|
|
|
|
|
+import { useFormatter, useNow, useTranslations } from 'next-intl'
|
|
|
import Image from 'next/image'
|
|
import Image from 'next/image'
|
|
|
import { Button } from '@/components/ui/button'
|
|
import { Button } from '@/components/ui/button'
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
@@ -9,8 +9,13 @@ import { Checkbox } from '@/components/ui/checkbox'
|
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
|
import { Input } from '@/components/ui/input'
|
|
import { Input } from '@/components/ui/input'
|
|
|
import { useGlassModal } from '@/components/glass-modal'
|
|
import { useGlassModal } from '@/components/glass-modal'
|
|
|
-import { AlertTriangle, ArrowRight, Download, FileArchive, Loader2, Trash2, Upload } from 'lucide-react'
|
|
|
|
|
|
|
+import { AlertTriangle, ArrowRight, Download, FileArchive, Loader2, ShieldCheck, Trash2, Upload } from 'lucide-react'
|
|
|
import { ReadOnlyBanner, ReadOnlyWrapper } from '../read-only-guard'
|
|
import { ReadOnlyBanner, ReadOnlyWrapper } from '../read-only-guard'
|
|
|
|
|
+import {
|
|
|
|
|
+ SUPPORT_OPEN_EVENT,
|
|
|
|
|
+ isSupportBubbleHidden,
|
|
|
|
|
+ setSupportBubbleHidden,
|
|
|
|
|
+} from '@/features/support/Lib/supportVisibility'
|
|
|
import { deleteContent } from '@/features/settings/Actions/deleteContent'
|
|
import { deleteContent } from '@/features/settings/Actions/deleteContent'
|
|
|
import { toast } from 'sonner'
|
|
import { toast } from 'sonner'
|
|
|
import { useRouter } from 'next/navigation'
|
|
import { useRouter } from 'next/navigation'
|
|
@@ -73,8 +78,17 @@ const ALL_TRUE: ExportOptions = {
|
|
|
files: true,
|
|
files: true,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-export function DataSettings({ contentCounts }: { contentCounts: ContentCounts }) {
|
|
|
|
|
|
|
+export function DataSettings({
|
|
|
|
|
+ contentCounts,
|
|
|
|
|
+ lastBackupAt = null,
|
|
|
|
|
+}: {
|
|
|
|
|
+ contentCounts: ContentCounts
|
|
|
|
|
+ lastBackupAt?: string | null
|
|
|
|
|
+}) {
|
|
|
const t = useTranslations('settings')
|
|
const t = useTranslations('settings')
|
|
|
|
|
+ const format = useFormatter()
|
|
|
|
|
+ // Keeps the "Last backup: x ago" label ticking without a reload.
|
|
|
|
|
+ const now = useNow({ updateInterval: 60_000 })
|
|
|
const router = useRouter()
|
|
const router = useRouter()
|
|
|
const modal = useGlassModal()
|
|
const modal = useGlassModal()
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
@@ -313,6 +327,51 @@ export function DataSettings({ contentCounts }: { contentCounts: ContentCounts }
|
|
|
</p>
|
|
</p>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ {lastBackupAt && (() => {
|
|
|
|
|
+ const ageHours = (now.getTime() - new Date(lastBackupAt).getTime()) / 3_600_000
|
|
|
|
|
+ // Hourly schedule: green while fresh, amber once a couple of runs were
|
|
|
|
|
+ // missed, red when a whole day has passed.
|
|
|
|
|
+ const dot = ageHours < 3 ? 'bg-emerald-500' : ageHours < 26 ? 'bg-amber-500' : 'bg-red-500'
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Card className="gap-2 border border-primary/30 shadow-sm">
|
|
|
|
|
+ <CardHeader>
|
|
|
|
|
+ <CardTitle className="flex items-center gap-2 text-base">
|
|
|
|
|
+ <ShieldCheck className="h-4 w-4" /> {t('data.backupStatus.title')}
|
|
|
|
|
+ </CardTitle>
|
|
|
|
|
+ </CardHeader>
|
|
|
|
|
+ <CardContent className="space-y-2">
|
|
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
|
|
+ {t('data.backupStatus.description')}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <p className="flex items-center gap-2 text-sm font-medium">
|
|
|
|
|
+ <span className={`inline-block h-2 w-2 rounded-full ${dot}`} />
|
|
|
|
|
+ {t('data.backupStatus.lastBackup')}{' '}
|
|
|
|
|
+ <span suppressHydrationWarning>
|
|
|
|
|
+ {format.relativeTime(new Date(lastBackupAt), now)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <p className="text-xs text-muted-foreground">
|
|
|
|
|
+ {t('data.backupStatus.supportPrompt')}{' '}
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ className="underline underline-offset-2 hover:text-foreground"
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ // Opening while hidden would dispatch into nothing, so make
|
|
|
|
|
+ // it visible first and let the widget mount before opening.
|
|
|
|
|
+ if (isSupportBubbleHidden()) {
|
|
|
|
|
+ setSupportBubbleHidden(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ requestAnimationFrame(() => window.dispatchEvent(new Event(SUPPORT_OPEN_EVENT)))
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {t('data.backupStatus.supportAction')}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </CardContent>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ )
|
|
|
|
|
+ })()}
|
|
|
|
|
+
|
|
|
<ReadOnlyWrapper>
|
|
<ReadOnlyWrapper>
|
|
|
<div className="grid gap-6 lg:grid-cols-12">
|
|
<div className="grid gap-6 lg:grid-cols-12">
|
|
|
{/* Export Card */}
|
|
{/* Export Card */}
|
|
@@ -434,7 +493,7 @@ export function DataSettings({ contentCounts }: { contentCounts: ContentCounts }
|
|
|
alt="LubeLog"
|
|
alt="LubeLog"
|
|
|
width={120}
|
|
width={120}
|
|
|
height={30}
|
|
height={30}
|
|
|
- className="h-auto object-contain"
|
|
|
|
|
|
|
+ className="w-[120px] h-auto object-contain"
|
|
|
unoptimized
|
|
unoptimized
|
|
|
/>
|
|
/>
|
|
|
</button>
|
|
</button>
|
|
@@ -450,7 +509,7 @@ export function DataSettings({ contentCounts }: { contentCounts: ContentCounts }
|
|
|
alt="Invoice Ninja"
|
|
alt="Invoice Ninja"
|
|
|
width={140}
|
|
width={140}
|
|
|
height={30}
|
|
height={30}
|
|
|
- className="h-auto object-contain"
|
|
|
|
|
|
|
+ className="w-[140px] h-auto object-contain"
|
|
|
unoptimized
|
|
unoptimized
|
|
|
/>
|
|
/>
|
|
|
</button>
|
|
</button>
|