feat: add skeleton loaders for improved loading state in Dashboard and Header components

This commit is contained in:
Arunavo Ray
2025-05-21 11:19:37 +05:30
parent 04e8b817d3
commit 97676f3b04
3 changed files with 238 additions and 147 deletions

View File

@@ -1,14 +1,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from '@/components/ui/card';
import { GitHubConfigForm } from "./GitHubConfigForm"; import { GitHubConfigForm } from './GitHubConfigForm';
import { GiteaConfigForm } from "./GiteaConfigForm"; import { GiteaConfigForm } from './GiteaConfigForm';
import { ScheduleConfigForm } from "./ScheduleConfigForm"; import { ScheduleConfigForm } from './ScheduleConfigForm';
import type { import type {
ConfigApiResponse, ConfigApiResponse,
GiteaConfig, GiteaConfig,
@@ -16,12 +16,13 @@ import type {
SaveConfigApiRequest, SaveConfigApiRequest,
SaveConfigApiResponse, SaveConfigApiResponse,
ScheduleConfig, ScheduleConfig,
} from "@/types/config"; } from '@/types/config';
import { Button } from "../ui/button"; import { Button } from '../ui/button';
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from '@/hooks/useAuth';
import { apiRequest } from "@/lib/utils"; import { apiRequest } from '@/lib/utils';
import { Copy, CopyCheck, RefreshCw } from "lucide-react"; import { Copy, CopyCheck, RefreshCw } from 'lucide-react';
import { toast } from "sonner"; import { toast } from 'sonner';
import { Skeleton } from '@/components/ui/skeleton';
type ConfigState = { type ConfigState = {
githubConfig: GitHubConfig; githubConfig: GitHubConfig;
@@ -32,8 +33,8 @@ type ConfigState = {
export function ConfigTabs() { export function ConfigTabs() {
const [config, setConfig] = useState<ConfigState>({ const [config, setConfig] = useState<ConfigState>({
githubConfig: { githubConfig: {
username: "", username: '',
token: "", token: '',
skipForks: false, skipForks: false,
privateRepositories: false, privateRepositories: false,
mirrorIssues: false, mirrorIssues: false,
@@ -41,16 +42,14 @@ export function ConfigTabs() {
preserveOrgStructure: false, preserveOrgStructure: false,
skipStarredIssues: false, skipStarredIssues: false,
}, },
giteaConfig: { giteaConfig: {
url: "", url: '',
username: "", username: '',
token: "", token: '',
organization: "github-mirrors", organization: 'github-mirrors',
visibility: "public", visibility: 'public',
starredReposOrg: "github", starredReposOrg: 'github',
}, },
scheduleConfig: { scheduleConfig: {
enabled: false, enabled: false,
interval: 3600, interval: 3600,
@@ -58,27 +57,21 @@ export function ConfigTabs() {
}); });
const { user, refreshUser } = useAuth(); const { user, refreshUser } = useAuth();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [dockerCode, setDockerCode] = useState<string>(""); const [dockerCode, setDockerCode] = useState<string>('');
const [isCopied, setIsCopied] = useState<boolean>(false); const [isCopied, setIsCopied] = useState<boolean>(false);
const [isSyncing, setIsSyncing] = useState<boolean>(false); const [isSyncing, setIsSyncing] = useState<boolean>(false);
const [isConfigSaved, setIsConfigSaved] = useState<boolean>(false); const [isConfigSaved, setIsConfigSaved] = useState<boolean>(false);
// Check if all required fields are filled to enable the Save Configuration button
const isConfigFormValid = (): boolean => { const isConfigFormValid = (): boolean => {
const { githubConfig, giteaConfig } = config; const { githubConfig, giteaConfig } = config;
// Check GitHub required fields
const isGitHubValid = !!( const isGitHubValid = !!(
githubConfig.username?.trim() && githubConfig.token?.trim() githubConfig.username.trim() && githubConfig.token.trim()
); );
// Check Gitea required fields
const isGiteaValid = !!( const isGiteaValid = !!(
giteaConfig.url?.trim() && giteaConfig.url.trim() &&
giteaConfig.username?.trim() && giteaConfig.username.trim() &&
giteaConfig.token?.trim() giteaConfig.token.trim()
); );
return isGitHubValid && isGiteaValid; return isGitHubValid && isGiteaValid;
}; };
@@ -86,11 +79,12 @@ export function ConfigTabs() {
const updateLastAndNextRun = () => { const updateLastAndNextRun = () => {
const lastRun = config.scheduleConfig.lastRun const lastRun = config.scheduleConfig.lastRun
? new Date(config.scheduleConfig.lastRun) ? new Date(config.scheduleConfig.lastRun)
: new Date(); // fallback to now if lastRun is null : new Date();
const intervalInSeconds = config.scheduleConfig.interval; const intervalInSeconds = config.scheduleConfig.interval;
const nextRun = new Date(lastRun.getTime() + intervalInSeconds * 1000); const nextRun = new Date(
lastRun.getTime() + intervalInSeconds * 1000,
setConfig((prev) => ({ );
setConfig(prev => ({
...prev, ...prev,
scheduleConfig: { scheduleConfig: {
...prev.scheduleConfig, ...prev.scheduleConfig,
@@ -99,37 +93,31 @@ export function ConfigTabs() {
}, },
})); }));
}; };
updateLastAndNextRun(); updateLastAndNextRun();
}, [config.scheduleConfig.interval]); }, [config.scheduleConfig.interval]);
const handleImportGitHubData = async () => { const handleImportGitHubData = async () => {
if (!user?.id) return;
setIsSyncing(true);
try { try {
if (!user?.id) return;
setIsSyncing(true);
const result = await apiRequest<{ success: boolean; message?: string }>( const result = await apiRequest<{ success: boolean; message?: string }>(
`/sync?userId=${user.id}`, `/sync?userId=${user.id}`,
{ { method: 'POST' },
method: "POST",
}
); );
result.success
if (result.success) { ? toast.success(
toast.success( 'GitHub data imported successfully! Head to the Dashboard to start mirroring repositories.',
"GitHub data imported successfully! Head to the Dashboard to start mirroring repositories." )
); : toast.error(
} else { `Failed to import GitHub data: ${
toast.error( result.message || 'Unknown error'
`Failed to import GitHub data: ${result.message || "Unknown error"}` }`,
); );
}
} catch (error) { } catch (error) {
toast.error( toast.error(
`Error importing GitHub data: ${ `Error importing GitHub data: ${
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
}` }`,
); );
} finally { } finally {
setIsSyncing(false); setIsSyncing(false);
@@ -137,94 +125,76 @@ export function ConfigTabs() {
}; };
const handleSaveConfig = async () => { const handleSaveConfig = async () => {
if (!user?.id) return;
const reqPayload: SaveConfigApiRequest = {
userId: user.id,
githubConfig: config.githubConfig,
giteaConfig: config.giteaConfig,
scheduleConfig: config.scheduleConfig,
};
try { try {
if (!user || !user.id) { const response = await fetch('/api/config', {
return; method: 'POST',
} headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
const reqPyload: SaveConfigApiRequest = {
userId: user.id,
githubConfig: config.githubConfig,
giteaConfig: config.giteaConfig,
scheduleConfig: config.scheduleConfig,
};
const response = await fetch("/api/config", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(reqPyload),
}); });
const result: SaveConfigApiResponse = await response.json(); const result: SaveConfigApiResponse = await response.json();
if (result.success) { if (result.success) {
await refreshUser(); await refreshUser();
setIsConfigSaved(true); setIsConfigSaved(true);
toast.success( toast.success(
"Configuration saved successfully! Now import your GitHub data to begin." 'Configuration saved successfully! Now import your GitHub data to begin.',
); );
} else { } else {
toast.error( toast.error(
`Failed to save configuration: ${result.message || "Unknown error"}` `Failed to save configuration: ${result.message || 'Unknown error'}`,
); );
} }
} catch (error) { } catch (error) {
toast.error( toast.error(
`An error occurred while saving the configuration: ${ `An error occurred while saving the configuration: ${
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
}` }`,
); );
} }
}; };
useEffect(() => { useEffect(() => {
if (!user) return;
const fetchConfig = async () => { const fetchConfig = async () => {
setIsLoading(true);
try { try {
if (!user) {
return;
}
setIsLoading(true);
const response = await apiRequest<ConfigApiResponse>( const response = await apiRequest<ConfigApiResponse>(
`/config?userId=${user.id}`, `/config?userId=${user.id}`,
{ { method: 'GET' },
method: "GET",
}
); );
// Check if we have a valid config response
if (response && !response.error) { if (response && !response.error) {
setConfig({ setConfig({
githubConfig: response.githubConfig || config.githubConfig, githubConfig:
giteaConfig: response.giteaConfig || config.giteaConfig, response.githubConfig || config.githubConfig,
scheduleConfig: response.scheduleConfig || config.scheduleConfig, giteaConfig:
response.giteaConfig || config.giteaConfig,
scheduleConfig:
response.scheduleConfig || config.scheduleConfig,
}); });
if (response.id) setIsConfigSaved(true);
// If we got a valid config from the server, it means it was previously saved
if (response.id) {
setIsConfigSaved(true);
}
} }
// If there's an error, we'll just use the default config defined in state
setIsLoading(false);
} catch (error) { } catch (error) {
// Don't show error for first-time users, just use the default config console.warn(
console.warn("Could not fetch configuration, using defaults:", error); 'Could not fetch configuration, using defaults:',
} finally { error,
setIsLoading(false); );
} }
setIsLoading(false);
}; };
fetchConfig(); fetchConfig();
}, [user]); }, [user]);
useEffect(() => { useEffect(() => {
const generateDockerCode = () => { const generateDockerCode = () => `
return `services: services:
gitea-mirror: gitea-mirror:
image: arunavo4/gitea-mirror:latest image: arunavo4/gitea-mirror:latest
restart: unless-stopped restart: unless-stopped
@@ -243,27 +213,93 @@ export function ConfigTabs() {
- GITEA_ORGANIZATION=${config.giteaConfig.organization} - GITEA_ORGANIZATION=${config.giteaConfig.organization}
- GITEA_ORG_VISIBILITY=${config.giteaConfig.visibility} - GITEA_ORG_VISIBILITY=${config.giteaConfig.visibility}
- DELAY=${config.scheduleConfig.interval}`; - DELAY=${config.scheduleConfig.interval}`;
}; setDockerCode(generateDockerCode());
const code = generateDockerCode();
setDockerCode(code);
}, [config]); }, [config]);
const handleCopyToClipboard = (text: string) => { const handleCopyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then( navigator.clipboard.writeText(text).then(
() => { () => {
setIsCopied(true); setIsCopied(true);
toast.success("Docker configuration copied to clipboard!"); toast.success('Docker configuration copied to clipboard!');
setTimeout(() => setIsCopied(false), 2000); setTimeout(() => setIsCopied(false), 2000);
}, },
(err) => { () => toast.error('Could not copy text to clipboard.'),
toast.error("Could not copy text to clipboard.");
}
); );
}; };
function ConfigCardSkeleton() {
return (
<Card>
<CardHeader className="flex-row justify-between">
<div className="flex flex-col gap-y-1.5 m-0">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<div className="flex gap-x-4">
<Skeleton className="h-10 w-36" />
<Skeleton className="h-10 w-36" />
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-y-4">
<div className="flex gap-x-4">
<div className="w-1/2 border rounded-lg p-4">
<div className="flex justify-between items-center mb-4">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-9 w-32" />
</div>
<div className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-32 w-full" />
</div>
</div>
<div className="w-1/2 border rounded-lg p-4">
<div className="flex justify-between items-center mb-4">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-9 w-32" />
</div>
<div className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
</div>
</div>
<div className="border rounded-lg p-4">
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-8 w-32" />
</div>
</div>
</div>
</CardContent>
</Card>
);
}
function DockerConfigSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="relative">
<Skeleton className="h-8 w-8 absolute top-4 right-10 rounded-md" />
<Skeleton className="h-48 w-full rounded-md" />
</CardContent>
</Card>
);
}
return isLoading ? ( return isLoading ? (
<div>loading...</div> <div className="flex flex-col gap-y-6">
<ConfigCardSkeleton />
<DockerConfigSkeleton />
</div>
) : ( ) : (
<div className="flex flex-col gap-y-6"> <div className="flex flex-col gap-y-6">
<Card> <Card>
@@ -275,17 +311,16 @@ export function ConfigTabs() {
mirroring. mirroring.
</CardDescription> </CardDescription>
</div> </div>
<div className="flex gap-x-4"> <div className="flex gap-x-4">
<Button <Button
onClick={handleImportGitHubData} onClick={handleImportGitHubData}
disabled={isSyncing || !isConfigSaved} disabled={isSyncing || !isConfigSaved}
title={ title={
!isConfigSaved !isConfigSaved
? "Save configuration first" ? 'Save configuration first'
: isSyncing : isSyncing
? "Import in progress" ? 'Import in progress'
: "Import GitHub Data" : 'Import GitHub Data'
} }
> >
{isSyncing ? ( {isSyncing ? (
@@ -305,66 +340,57 @@ export function ConfigTabs() {
disabled={!isConfigFormValid()} disabled={!isConfigFormValid()}
title={ title={
!isConfigFormValid() !isConfigFormValid()
? "Please fill all required fields" ? 'Please fill all required fields'
: "Save Configuration" : 'Save Configuration'
} }
> >
Save Configuration Save Configuration
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col gap-y-4"> <div className="flex flex-col gap-y-4">
<div className="flex gap-x-4"> <div className="flex gap-x-4">
<GitHubConfigForm <GitHubConfigForm
config={config.githubConfig} config={config.githubConfig}
setConfig={(update) => setConfig={update =>
setConfig((prev) => ({ setConfig(prev => ({
...prev, ...prev,
githubConfig: githubConfig:
typeof update === "function" typeof update === 'function'
? update(prev.githubConfig) ? update(prev.githubConfig)
: update, : update,
})) }))
} }
/> />
<GiteaConfigForm <GiteaConfigForm
config={config?.giteaConfig ?? ({} as GiteaConfig)} config={config.giteaConfig}
setConfig={(update) => setConfig={update =>
setConfig((prev) => ({ setConfig(prev => ({
...prev, ...prev,
giteaConfig: giteaConfig:
typeof update === "function" typeof update === 'function'
? update(prev.giteaConfig) ? update(prev.giteaConfig)
: update, : update,
githubConfig: prev?.githubConfig ?? ({} as GitHubConfig),
scheduleConfig:
prev?.scheduleConfig ?? ({} as ScheduleConfig),
})) }))
} }
/> />
</div> </div>
<ScheduleConfigForm <ScheduleConfigForm
config={config?.scheduleConfig ?? ({} as ScheduleConfig)} config={config.scheduleConfig}
setConfig={(update) => setConfig={update =>
setConfig((prev) => ({ setConfig(prev => ({
...prev, ...prev,
scheduleConfig: scheduleConfig:
typeof update === "function" typeof update === 'function'
? update(prev.scheduleConfig) ? update(prev.scheduleConfig)
: update, : update,
githubConfig: prev?.githubConfig ?? ({} as GitHubConfig),
giteaConfig: prev?.giteaConfig ?? ({} as GiteaConfig),
})) }))
} }
/> />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Docker Configuration</CardTitle> <CardTitle>Docker Configuration</CardTitle>
@@ -372,7 +398,6 @@ export function ConfigTabs() {
Equivalent Docker configuration for your current settings. Equivalent Docker configuration for your current settings.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="relative"> <CardContent className="relative">
<Button <Button
variant="outline" variant="outline"
@@ -386,7 +411,6 @@ export function ConfigTabs() {
<Copy className="text-muted-foreground" /> <Copy className="text-muted-foreground" />
)} )}
</Button> </Button>
<pre className="bg-muted p-4 rounded-md overflow-auto text-sm"> <pre className="bg-muted p-4 rounded-md overflow-auto text-sm">
{dockerCode} {dockerCode}
</pre> </pre>

View File

@@ -9,6 +9,8 @@ import { apiRequest } from "@/lib/utils";
import type { DashboardApiResponse } from "@/types/dashboard"; import type { DashboardApiResponse } from "@/types/dashboard";
import { useSSE } from "@/hooks/useSEE"; import { useSSE } from "@/hooks/useSEE";
import { toast } from "sonner"; import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function Dashboard() { export function Dashboard() {
const { user } = useAuth(); const { user } = useAuth();
@@ -59,8 +61,6 @@ export function Dashboard() {
return; return;
} }
setIsLoading(false);
const response = await apiRequest<DashboardApiResponse>( const response = await apiRequest<DashboardApiResponse>(
`/dashboard?userId=${user.id}`, `/dashboard?userId=${user.id}`,
{ {
@@ -93,8 +93,61 @@ export function Dashboard() {
fetchDashboardData(); fetchDashboardData();
}, [user]); }, [user]);
// Status Card Skeleton component
function StatusCardSkeleton() {
return (
<Card className="overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium">
<Skeleton className="h-4 w-24" />
</CardTitle>
<Skeleton className="h-4 w-4 rounded-full" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16 mb-1" />
<Skeleton className="h-3 w-32" />
</CardContent>
</Card>
);
}
return isLoading || !connected ? ( return isLoading || !connected ? (
<div>loading...</div> <div className="flex flex-col gap-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatusCardSkeleton />
<StatusCardSkeleton />
<StatusCardSkeleton />
<StatusCardSkeleton />
</div>
<div className="flex gap-x-6 items-start">
{/* Repository List Skeleton */}
<div className="w-1/2 border rounded-lg p-4">
<div className="flex justify-between items-center mb-4">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-9 w-24" />
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</div>
{/* Recent Activity Skeleton */}
<div className="w-1/2 border rounded-lg p-4">
<div className="flex justify-between items-center mb-4">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-9 w-24" />
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</div>
</div>
</div>
) : ( ) : (
<div className="flex flex-col gap-y-6"> <div className="flex flex-col gap-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">

View File

@@ -4,9 +4,10 @@ import { SiGitea } from "react-icons/si";
import { ModeToggle } from "@/components/theme/ModeToggle"; import { ModeToggle } from "@/components/theme/ModeToggle";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { toast } from "sonner"; import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
export function Header() { export function Header() {
const { user, logout } = useAuth(); const { user, logout, isLoading } = useAuth();
const handleLogout = async () => { const handleLogout = async () => {
toast.success("Logged out successfully"); toast.success("Logged out successfully");
@@ -15,6 +16,16 @@ export function Header() {
logout(); logout();
}; };
// Auth buttons skeleton loader
function AuthButtonsSkeleton() {
return (
<>
<Skeleton className="h-10 w-10 rounded-full" /> {/* Avatar placeholder */}
<Skeleton className="h-10 w-24" /> {/* Button placeholder */}
</>
);
}
return ( return (
<header className="border-b bg-background"> <header className="border-b bg-background">
<div className="flex h-[4.5rem] items-center justify-between px-6"> <div className="flex h-[4.5rem] items-center justify-between px-6">
@@ -25,7 +36,10 @@ export function Header() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<ModeToggle /> <ModeToggle />
{user ? (
{isLoading ? (
<AuthButtonsSkeleton />
) : user ? (
<> <>
<Avatar> <Avatar>
<AvatarImage src="" alt="@shadcn" /> <AvatarImage src="" alt="@shadcn" />