feat: add sync status
This commit is contained in:
parent
aa51ae523d
commit
74f31a723a
9 changed files with 224 additions and 145 deletions
|
|
@ -186,7 +186,7 @@ export default function Home({ user }: { user: User }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter component */}
|
{/* Filter component */}
|
||||||
<div className="flex gap-1 pb-0 pt-2">
|
<div className="flex gap-2 pb-0 pt-2">
|
||||||
<Select value={subjectFilter} onValueChange={setSubject}>
|
<Select value={subjectFilter} onValueChange={setSubject}>
|
||||||
<SelectTrigger className="rounded-full data-[placeholder]:text-primary">
|
<SelectTrigger className="rounded-full data-[placeholder]:text-primary">
|
||||||
<SelectValue placeholder="Matière" />
|
<SelectValue placeholder="Matière" />
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export default function SettingsPage({ user }: { user: User }) {
|
||||||
value: "user",
|
value: "user",
|
||||||
label: "Profil",
|
label: "Profil",
|
||||||
icon: <UserIcon className="h-4 w-4" />,
|
icon: <UserIcon className="h-4 w-4" />,
|
||||||
content: <Profile />,
|
content: <Profile user={user} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "preferences",
|
value: "preferences",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Palette, Save, Undo } from "lucide-react";
|
import { Moon, Palette, Save, Sun, Undo } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
|
|
@ -24,10 +24,13 @@ import EmojiInput from "./emoji-input";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useDisplayedTheme } from "../theme-provider";
|
||||||
|
|
||||||
export default function Preferences({ user }: { user: User }) {
|
export default function Preferences({ user }: { user: User }) {
|
||||||
const [preferences, setPreferences] = useState(user.preferences || []);
|
const [preferences, setPreferences] = useState(user.preferences || []);
|
||||||
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
||||||
|
const { theme, setTheme } = useDisplayedTheme();
|
||||||
|
|
||||||
// TODO: Add a loading state and error handling
|
// TODO: Add a loading state and error handling
|
||||||
const { subjects, isLoading, isError } = useSubjects();
|
const { subjects, isLoading, isError } = useSubjects();
|
||||||
|
|
||||||
|
|
@ -72,7 +75,7 @@ export default function Preferences({ user }: { user: User }) {
|
||||||
// Invalidate the user query to refresh the data
|
// Invalidate the user query to refresh the data
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
queryClient.removeQueries({ queryKey: ["user"] });
|
queryClient.removeQueries({ queryKey: ["user"] });
|
||||||
|
|
||||||
toast.success("Vos préférences ont été sauvegardé avec succès !");
|
toast.success("Vos préférences ont été sauvegardé avec succès !");
|
||||||
setUnsavedChanges(false);
|
setUnsavedChanges(false);
|
||||||
})
|
})
|
||||||
|
|
@ -87,6 +90,41 @@ export default function Preferences({ user }: { user: User }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Theme card: darmode light mode */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Moon className="h-5 w-5" />
|
||||||
|
Thème
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Personnalisez l'apparence de l'application.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* TODO: Save user theme */}
|
||||||
|
{/* Theme toggle button */}
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<>
|
||||||
|
<Sun className="mr-2 h-4 w-4" />
|
||||||
|
<span>Activer le mode clair</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Moon className="mr-2 h-4 w-4" />
|
||||||
|
<span>Activer le mode sombre</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Subject Customization Section */}
|
{/* Subject Customization Section */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,88 @@
|
||||||
import { Trash } from "lucide-react";
|
import { LogOut, Trash } from "lucide-react";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { clearCache } from "~/lib/utils";
|
import { clearCache } from "~/lib/utils";
|
||||||
|
import { Card, CardContent } from "../ui/card";
|
||||||
|
import { Avatar, AvatarFallback } from "../ui/avatar";
|
||||||
|
import { logout, type User } from "~/lib/api";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
|
export default function Profile({ user }: { user: User }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const useLogout = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Logout, invalidate user cache, and redirect to login
|
||||||
|
return async () => {
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
queryClient.removeQueries({ queryKey: ["user"] });
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Logout failed:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = useLogout();
|
||||||
|
|
||||||
export default function Profile() {
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Card className="w-full max-w-2xl mx-auto shadow-lg py-6 pb-0">
|
||||||
<Button variant="destructive" className="w-full text-white" onClick={clearCache}>
|
<CardContent className="space-y-6">
|
||||||
<Trash className="mr-2 h-4 w-4" />
|
{/* Avatar Section */}
|
||||||
Vider le cache
|
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||||
</Button>
|
<div className="relative">
|
||||||
</div>
|
<Avatar className="h-20 w-20 sm:h-24 sm:w-24">
|
||||||
|
{/* <AvatarImage src={profile.avatar || "/placeholder.svg"} alt={profile.name} /> */}
|
||||||
|
<AvatarFallback className="text-lg">
|
||||||
|
{user.fullName
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
<div className="text-center sm:text-left">
|
||||||
|
<h3 className="text-xl font-semibold">{user.fullName}</h3>
|
||||||
|
<p className="text-muted-foreground">{user.email}</p>
|
||||||
|
<Badge className="mt-2">{user.className}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Fields */}
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name">Nom & Prénom</Label>
|
||||||
|
<p className="px-3 py-2 bg-muted rounded-md">{user.fullName}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="email">Adresse Email</Label>
|
||||||
|
<p className="px-3 py-2 bg-muted rounded-md">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logout Button */}
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full text-white"
|
||||||
|
onClick={logout}
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
Se déconnecter
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full text-white"
|
||||||
|
onClick={clearCache}
|
||||||
|
>
|
||||||
|
<Trash className="mr-2 h-4 w-4" />
|
||||||
|
Vider le cache
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
96
app/components/sync-status.tsx
Normal file
96
app/components/sync-status.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "~/components/ui/popover";
|
||||||
|
import { Cloud, CloudOff, Wifi, WifiOff } from "lucide-react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
export function SyncButton() {
|
||||||
|
const [isSync, setIsSync] = useState(true);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const lastSync = new Date(); // TODO: Replace with actual last sync date
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
if (isSync) return <Cloud className="w-6! h-6!" />;
|
||||||
|
return <CloudOff className="w-6! h-6!" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
if (isSync) return "text-primary";
|
||||||
|
return "text-muted-foreground";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLastSync = (date: Date | null) => {
|
||||||
|
if (!date) return "N/A";
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (minutes < 1) return "A l'instant";
|
||||||
|
if (minutes < 60) return `il y a ${minutes}m`;
|
||||||
|
if (hours < 24) return `il y a ${hours}h`;
|
||||||
|
return `il y a ${days}j`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn("h-9 w-9 p-0 transition-colors", getStatusColor())}
|
||||||
|
// TODO: Implement sync functionality
|
||||||
|
onClick={() => {}}
|
||||||
|
>
|
||||||
|
{getIcon()}
|
||||||
|
<span className="sr-only">{isSync ? "Sync" : "Offline"}</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-64" align="end">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={cn("flex items-center gap-2", getStatusColor())}>
|
||||||
|
{isSync ? (
|
||||||
|
<Wifi className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium">
|
||||||
|
Status
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"ml-auto px-2 py-1 rounded-full text-xs font-medium",
|
||||||
|
isSync
|
||||||
|
? "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400"
|
||||||
|
: "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSync ? "Connecté" : "Erreur"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Dernière mis à jour :</span>
|
||||||
|
<span>{formatLastSync(lastSync)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* TODO:
|
||||||
|
{syncStatus.isOnline && syncStatus.status !== "syncing" && (
|
||||||
|
<Button size="sm" className="w-full" onClick={handleSync}>
|
||||||
|
Sync Now
|
||||||
|
</Button>
|
||||||
|
)} */}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "~/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
ChevronDown,
|
|
||||||
LineChartIcon,
|
|
||||||
LogOut,
|
|
||||||
Moon,
|
|
||||||
Settings,
|
|
||||||
Sun,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useDisplayedTheme } from "~/components/theme-provider";
|
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import { logout, type User } from "~/lib/api";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
export default function UserDropdown({ user }: { user: User }) {
|
|
||||||
const { theme, setTheme } = useDisplayedTheme();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const useLogout = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// Logout, invalidate user cache, and redirect to login
|
|
||||||
return async () => {
|
|
||||||
try {
|
|
||||||
await logout();
|
|
||||||
queryClient.removeQueries({ queryKey: ["user"] });
|
|
||||||
navigate("/login", { replace: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Logout failed:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = useLogout();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
className={
|
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive h-9 px-4 py-2 has-[>svg]:px-3 hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 flex items-center gap-2 px-2"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Avatar className="h-10 w-10">
|
|
||||||
<AvatarFallback>{getAvatar(user.fullName)}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<span className="hidden md:inline-block font-medium">
|
|
||||||
{user.fullName}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
<div className="flex flex-col space-y-1">
|
|
||||||
<p className="text-sm font-medium leading-none">{user.fullName}</p>
|
|
||||||
<p className="text-xs leading-none text-muted-foreground">
|
|
||||||
{user.className}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
|
||||||
>
|
|
||||||
{theme === "dark" ? (
|
|
||||||
<>
|
|
||||||
<Sun className="mr-2 h-4 w-4" />
|
|
||||||
<span>Mode clair</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Moon className="mr-2 h-4 w-4" />
|
|
||||||
<span>Mode sombre</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
navigate("/settings");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
|
||||||
<span>Paramètres</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
navigate("/progress");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LineChartIcon className="mr-2 h-4 w-4" />
|
|
||||||
<span>Progression</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
|
||||||
<span>Se déconnecter</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAvatar = (name: string) => {
|
|
||||||
return name
|
|
||||||
.split(" ")
|
|
||||||
.map((word) => word.charAt(0))
|
|
||||||
.join("")
|
|
||||||
.toUpperCase();
|
|
||||||
};
|
|
||||||
|
|
@ -58,7 +58,7 @@ export function MainLayout({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto py-8 px-4">
|
<main className="container mx-auto py-8 px-4">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-4">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Navigate } from "react-router";
|
||||||
import Error from "~/components/error";
|
import Error from "~/components/error";
|
||||||
import HomePage from "~/components/home";
|
import HomePage from "~/components/home";
|
||||||
import Loader from "~/components/loader";
|
import Loader from "~/components/loader";
|
||||||
import UserDropdown from "~/components/user-dropdown";
|
import { SyncButton } from "~/components/sync-status";
|
||||||
import { MainLayout } from "~/layout";
|
import { MainLayout } from "~/layout";
|
||||||
import { AUTH_ERROR, useUser } from "~/lib/api";
|
import { AUTH_ERROR, useUser } from "~/lib/api";
|
||||||
import { forceReload } from "~/lib/utils";
|
import { forceReload } from "~/lib/utils";
|
||||||
|
|
@ -25,7 +25,7 @@ export default function Home() {
|
||||||
<h1 className="text-2xl font-bold" onClick={forceReload}>
|
<h1 className="text-2xl font-bold" onClick={forceReload}>
|
||||||
Khollisé - {user.className} ⚔️
|
Khollisé - {user.className} ⚔️
|
||||||
</h1>
|
</h1>
|
||||||
<UserDropdown user={user} />
|
<SyncButton />
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { Navigate } from "react-router";
|
||||||
import Error from "~/components/error";
|
import Error from "~/components/error";
|
||||||
import SettingsPage from "~/components/settings";
|
import SettingsPage from "~/components/settings";
|
||||||
import Loader from "~/components/loader";
|
import Loader from "~/components/loader";
|
||||||
import UserDropdown from "~/components/user-dropdown";
|
|
||||||
import { MainLayout } from "~/layout";
|
import { MainLayout } from "~/layout";
|
||||||
import { AUTH_ERROR, useUser } from "~/lib/api";
|
import { AUTH_ERROR, useUser } from "~/lib/api";
|
||||||
import { forceReload } from "~/lib/utils";
|
import { forceReload } from "~/lib/utils";
|
||||||
|
|
@ -25,7 +24,6 @@ export default function Home() {
|
||||||
<h1 className="text-2xl font-bold" onClick={forceReload}>
|
<h1 className="text-2xl font-bold" onClick={forceReload}>
|
||||||
Khollisé - {user.className} ⚔️
|
Khollisé - {user.className} ⚔️
|
||||||
</h1>
|
</h1>
|
||||||
<UserDropdown user={user} />
|
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue