feat(04-02): upgrade QuickAddPage and SettingsPage with PageShell and skeletons

- QuickAddPage: adopt PageShell for header with title and Add button
- QuickAddPage: replace return null with skeleton loading state (5-row table skeleton)
- SettingsPage: adopt PageShell for header with title
- SettingsPage: remove duplicate h1 heading (was shown alongside CardTitle)
- SettingsPage: remove CardHeader and CardTitle (PageShell provides page-level title)
- SettingsPage: replace return null with skeleton loading state
- SettingsPage: clean up imports — remove CardHeader/CardTitle
This commit is contained in:
2026-03-17 16:16:23 +01:00
parent e9497e42a7
commit ba19c30f07
2 changed files with 356 additions and 0 deletions

215
src/pages/QuickAddPage.tsx Normal file
View File

@@ -0,0 +1,215 @@
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { Plus, Pencil, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { useQuickAdd } from "@/hooks/useQuickAdd"
import type { QuickAddItem } from "@/lib/types"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { PageShell } from "@/components/shared/PageShell"
export default function QuickAddPage() {
const { t } = useTranslation()
const { items, loading, create, update, remove } = useQuickAdd()
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<QuickAddItem | null>(null)
const [name, setName] = useState("")
const [icon, setIcon] = useState("")
// ------------------------------------------------------------------
// Dialog helpers
// ------------------------------------------------------------------
function openCreate() {
setEditing(null)
setName("")
setIcon("")
setDialogOpen(true)
}
function openEdit(item: QuickAddItem) {
setEditing(item)
setName(item.name)
setIcon(item.icon ?? "")
setDialogOpen(true)
}
// ------------------------------------------------------------------
// Save handler
// ------------------------------------------------------------------
async function handleSave() {
try {
if (editing) {
await update.mutateAsync({
id: editing.id,
name: name.trim(),
icon: icon.trim() || null,
})
} else {
await create.mutateAsync({
name: name.trim(),
icon: icon.trim() || null,
})
}
setDialogOpen(false)
} catch {
toast.error(t("common.error"))
}
}
// ------------------------------------------------------------------
// Delete handler
// ------------------------------------------------------------------
async function handleDelete(id: string) {
try {
await remove.mutateAsync(id)
} catch {
toast.error(t("common.error"))
}
}
const isPending = create.isPending || update.isPending
if (loading) return (
<PageShell title={t("quickAdd.title")}>
<div className="space-y-1">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-4 px-4 py-2.5 border-b border-border">
<Skeleton className="h-5 w-10 rounded-full" />
<Skeleton className="h-4 w-36" />
<Skeleton className="ml-auto h-7 w-7 rounded-md" />
</div>
))}
</div>
</PageShell>
)
return (
<PageShell
title={t("quickAdd.title")}
action={
<Button onClick={openCreate} size="sm">
<Plus className="mr-1 size-4" />
{t("quickAdd.add")}
</Button>
}
>
{/* Empty state */}
{items.length === 0 ? (
<p className="text-muted-foreground">{t("quickAdd.empty")}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("categories.icon")}</TableHead>
<TableHead>{t("categories.name")}</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell>
{item.icon && (
<Badge variant="secondary">{item.icon}</Badge>
)}
</TableCell>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
aria-label={t("quickAdd.edit")}
onClick={() => openEdit(item)}
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("common.delete")}
onClick={() => void handleDelete(item.id)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{/* Add / Edit dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editing ? t("quickAdd.edit") : t("quickAdd.add")}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Name */}
<div className="space-y-2">
<Label>{t("categories.name")}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("categories.name")}
autoFocus
/>
</div>
{/* Icon */}
<div className="space-y-2">
<Label>{t("categories.icon")}</Label>
<Input
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="e.g. emoji or icon name"
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={isPending}
>
{t("common.cancel")}
</Button>
<Button
onClick={() => void handleSave()}
disabled={!name.trim() || isPending}
>
{t("common.save")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</PageShell>
)
}

141
src/pages/SettingsPage.tsx Normal file
View File

@@ -0,0 +1,141 @@
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { useAuth } from "@/hooks/useAuth"
import { supabase } from "@/lib/supabase"
import type { Profile } from "@/lib/types"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { PageShell } from "@/components/shared/PageShell"
export default function SettingsPage() {
const { t, i18n } = useTranslation()
const { user } = useAuth()
const [profile, setProfile] = useState<Profile | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!user) return
supabase
.from("profiles")
.select("*")
.eq("id", user.id)
.single()
.then(({ data }) => {
if (data) {
setProfile(data)
i18n.changeLanguage(data.locale)
}
setLoading(false)
})
}, [user, i18n])
async function handleSave() {
if (!profile || !user) return
setSaving(true)
const { error } = await supabase
.from("profiles")
.update({
display_name: profile.display_name,
locale: profile.locale,
currency: profile.currency,
})
.eq("id", user.id)
if (error) {
toast.error(t("common.error"))
} else {
i18n.changeLanguage(profile.locale)
toast.success(t("settings.saved"))
}
setSaving(false)
}
if (loading) return (
<PageShell title={t("settings.title")}>
<div className="max-w-lg">
<Card>
<CardContent className="space-y-4 pt-6">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
<Skeleton className="h-10 w-20" />
</CardContent>
</Card>
</div>
</PageShell>
)
return (
<PageShell title={t("settings.title")}>
<div className="max-w-lg">
<Card>
<CardContent className="space-y-4 pt-6">
<div className="space-y-2">
<Label>{t("settings.displayName")}</Label>
<Input
value={profile?.display_name ?? ""}
onChange={(e) =>
setProfile((p) => p && { ...p, display_name: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>{t("settings.language")}</Label>
<Select
value={profile?.locale ?? "en"}
onValueChange={(val) =>
setProfile((p) => p && { ...p, locale: val })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="de">Deutsch</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("settings.currency")}</Label>
<Select
value={profile?.currency ?? "EUR"}
onValueChange={(val) =>
setProfile((p) => p && { ...p, currency: val })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="GBP">GBP</SelectItem>
<SelectItem value="CHF">CHF</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleSave} disabled={saving}>
{t("settings.save")}
</Button>
</CardContent>
</Card>
</div>
</PageShell>
)
}