feat: add server time clock

This commit is contained in:
Aathil Felix
2025-11-01 18:03:40 +05:30
parent dadef000d5
commit 0f100c7bc8
4 changed files with 1463 additions and 1423 deletions
@@ -1,13 +1,13 @@
import { import {
AlertTriangle, AlertTriangle,
ArrowUpDown, ArrowUpDown,
BookIcon, BookIcon,
ExternalLinkIcon, ExternalLinkIcon,
FolderInput, FolderInput,
Loader2, Loader2,
MoreHorizontalIcon, MoreHorizontalIcon,
Search, Search,
TrashIcon, TrashIcon,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@@ -16,497 +16,497 @@ import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
import { DateTooltip } from "@/components/shared/date-tooltip"; import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip"; import { StatusTooltip } from "@/components/shared/status-tooltip";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input"; import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { HandleProject } from "./handle-project"; import { HandleProject } from "./handle-project";
import { ProjectEnvironment } from "./project-environment"; import { ProjectEnvironment } from "./project-environment";
import { TimeBadge } from "@/components/ui/time-badge";
export const ShowProjects = () => { export const ShowProjects = () => {
const utils = api.useUtils(); const utils = api.useUtils();
const { data, isLoading } = api.project.all.useQuery(); const { data, isLoading } = api.project.all.useQuery();
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { mutateAsync } = api.project.remove.useMutation(); const { mutateAsync } = api.project.remove.useMutation();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState<string>(() => { const [sortBy, setSortBy] = useState<string>(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
return localStorage.getItem("projectsSort") || "createdAt-desc"; return localStorage.getItem("projectsSort") || "createdAt-desc";
} }
return "createdAt-desc"; return "createdAt-desc";
}); });
useEffect(() => { useEffect(() => {
localStorage.setItem("projectsSort", sortBy); localStorage.setItem("projectsSort", sortBy);
}, [sortBy]); }, [sortBy]);
const filteredProjects = useMemo(() => { const filteredProjects = useMemo(() => {
if (!data) return []; if (!data) return [];
// First filter by search query // First filter by search query
const filtered = data.filter( const filtered = data.filter(
(project) => (project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase()) || project.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
project.description?.toLowerCase().includes(searchQuery.toLowerCase()), project.description?.toLowerCase().includes(searchQuery.toLowerCase())
); );
// Then sort the filtered results // Then sort the filtered results
const [field, direction] = sortBy.split("-"); const [field, direction] = sortBy.split("-");
return [...filtered].sort((a, b) => { return [...filtered].sort((a, b) => {
let comparison = 0; let comparison = 0;
switch (field) { switch (field) {
case "name": case "name":
comparison = a.name.localeCompare(b.name); comparison = a.name.localeCompare(b.name);
break; break;
case "createdAt": case "createdAt":
comparison = comparison =
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break; break;
case "services": { case "services": {
const aTotalServices = a.environments.reduce((total, env) => { const aTotalServices = a.environments.reduce((total, env) => {
return ( return (
total + total +
(env.applications?.length || 0) + (env.applications?.length || 0) +
(env.mariadb?.length || 0) + (env.mariadb?.length || 0) +
(env.mongo?.length || 0) + (env.mongo?.length || 0) +
(env.mysql?.length || 0) + (env.mysql?.length || 0) +
(env.postgres?.length || 0) + (env.postgres?.length || 0) +
(env.redis?.length || 0) + (env.redis?.length || 0) +
(env.compose?.length || 0) (env.compose?.length || 0)
); );
}, 0); }, 0);
const bTotalServices = b.environments.reduce((total, env) => { const bTotalServices = b.environments.reduce((total, env) => {
return ( return (
total + total +
(env.applications?.length || 0) + (env.applications?.length || 0) +
(env.mariadb?.length || 0) + (env.mariadb?.length || 0) +
(env.mongo?.length || 0) + (env.mongo?.length || 0) +
(env.mysql?.length || 0) + (env.mysql?.length || 0) +
(env.postgres?.length || 0) + (env.postgres?.length || 0) +
(env.redis?.length || 0) + (env.redis?.length || 0) +
(env.compose?.length || 0) (env.compose?.length || 0)
); );
}, 0); }, 0);
comparison = aTotalServices - bTotalServices; comparison = aTotalServices - bTotalServices;
break; break;
} }
default: default:
comparison = 0; comparison = 0;
} }
return direction === "asc" ? comparison : -comparison; return direction === "asc" ? comparison : -comparison;
}); });
}, [data, searchQuery, sortBy]); }, [data, searchQuery, sortBy]);
return ( return (
<> <>
<BreadcrumbSidebar <BreadcrumbSidebar
list={[{ name: "Projects", href: "/dashboard/projects" }]} list={[{ name: "Projects", href: "/dashboard/projects" }]}
/> />
<div className="w-full"> <div className="absolute top-5 right-5">
<Card className="h-full bg-sidebar p-2.5 rounded-xl "> <TimeBadge />
<div className="rounded-xl bg-background shadow-md "> </div>
<div className="flex justify-between gap-4 w-full items-center flex-wrap p-6"> <div className="w-full">
<CardHeader className="p-0"> <Card className="h-full bg-sidebar p-2.5 rounded-xl ">
<CardTitle className="text-xl flex flex-row gap-2"> <div className="rounded-xl bg-background shadow-md ">
<FolderInput className="size-6 text-muted-foreground self-center" /> <div className="flex justify-between gap-4 w-full items-center flex-wrap p-6">
Projects <CardHeader className="p-0">
</CardTitle> <CardTitle className="text-xl flex flex-row gap-2">
<CardDescription> <FolderInput className="size-6 text-muted-foreground self-center" />
Create and manage your projects Projects
</CardDescription> </CardTitle>
</CardHeader> <CardDescription>
Create and manage your projects
</CardDescription>
</CardHeader>
{(auth?.role === "owner" || auth?.canCreateProjects) && (
<div className="">
<HandleProject />
</div>
)}
</div>
{(auth?.role === "owner" || auth?.canCreateProjects) && ( <CardContent className="space-y-2 py-8 border-t gap-4 flex flex-col min-h-[60vh]">
<div className=""> {isLoading ? (
<HandleProject /> <div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[60vh]">
</div> <span>Loading...</span>
)} <Loader2 className="animate-spin size-4" />
</div> </div>
) : (
<>
<div className="flex max-sm:flex-col gap-4 items-center w-full">
<div className="flex-1 relative max-sm:w-full">
<FocusShortcutInput
placeholder="Filter projects..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pr-10"
/>
<CardContent className="space-y-2 py-8 border-t gap-4 flex flex-col min-h-[60vh]"> <Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
{isLoading ? ( </div>
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[60vh]"> <div className="flex items-center gap-2 min-w-48 max-sm:w-full">
<span>Loading...</span> <ArrowUpDown className="size-4 text-muted-foreground" />
<Loader2 className="animate-spin size-4" /> <Select value={sortBy} onValueChange={setSortBy}>
</div> <SelectTrigger className="w-full">
) : ( <SelectValue placeholder="Sort by..." />
<> </SelectTrigger>
<div className="flex max-sm:flex-col gap-4 items-center w-full"> <SelectContent>
<div className="flex-1 relative max-sm:w-full"> <SelectItem value="name-asc">Name (A-Z)</SelectItem>
<FocusShortcutInput <SelectItem value="name-desc">Name (Z-A)</SelectItem>
placeholder="Filter projects..." <SelectItem value="createdAt-desc">
value={searchQuery} Newest first
onChange={(e) => setSearchQuery(e.target.value)} </SelectItem>
className="pr-10" <SelectItem value="createdAt-asc">
/> Oldest first
</SelectItem>
<SelectItem value="services-desc">
Most services
</SelectItem>
<SelectItem value="services-asc">
Least services
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{filteredProjects?.length === 0 && (
<div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4">
<FolderInput className="size-8 self-center text-muted-foreground" />
<span className="text-center font-medium text-muted-foreground">
No projects found
</span>
</div>
)}
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
{filteredProjects?.map((project) => {
const emptyServices = project?.environments
.map(
(env) =>
env.applications.length === 0 &&
env.mariadb.length === 0 &&
env.mongo.length === 0 &&
env.mysql.length === 0 &&
env.postgres.length === 0 &&
env.redis.length === 0 &&
env.applications.length === 0 &&
env.compose.length === 0
)
.every(Boolean);
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" /> const totalServices = project?.environments
</div> .map(
<div className="flex items-center gap-2 min-w-48 max-sm:w-full"> (env) =>
<ArrowUpDown className="size-4 text-muted-foreground" /> env.mariadb.length +
<Select value={sortBy} onValueChange={setSortBy}> env.mongo.length +
<SelectTrigger className="w-full"> env.mysql.length +
<SelectValue placeholder="Sort by..." /> env.postgres.length +
</SelectTrigger> env.redis.length +
<SelectContent> env.applications.length +
<SelectItem value="name-asc">Name (A-Z)</SelectItem> env.compose.length
<SelectItem value="name-desc">Name (Z-A)</SelectItem> )
<SelectItem value="createdAt-desc"> .reduce((acc, curr) => acc + curr, 0);
Newest first
</SelectItem>
<SelectItem value="createdAt-asc">
Oldest first
</SelectItem>
<SelectItem value="services-desc">
Most services
</SelectItem>
<SelectItem value="services-asc">
Least services
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{filteredProjects?.length === 0 && (
<div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4">
<FolderInput className="size-8 self-center text-muted-foreground" />
<span className="text-center font-medium text-muted-foreground">
No projects found
</span>
</div>
)}
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
{filteredProjects?.map((project) => {
const emptyServices = project?.environments
.map(
(env) =>
env.applications.length === 0 &&
env.mariadb.length === 0 &&
env.mongo.length === 0 &&
env.mysql.length === 0 &&
env.postgres.length === 0 &&
env.redis.length === 0 &&
env.applications.length === 0 &&
env.compose.length === 0,
)
.every(Boolean);
const totalServices = project?.environments const haveServicesWithDomains = project?.environments
.map( .map(
(env) => (env) =>
env.mariadb.length + env.applications.length > 0 ||
env.mongo.length + env.compose.length > 0
env.mysql.length + )
env.postgres.length + .some(Boolean);
env.redis.length +
env.applications.length +
env.compose.length,
)
.reduce((acc, curr) => acc + curr, 0);
const haveServicesWithDomains = project?.environments return (
.map( <div
(env) => key={project.projectId}
env.applications.length > 0 || className="w-full lg:max-w-md">
env.compose.length > 0, <Link
) href={`/dashboard/project/${project.projectId}/environment/${project?.environments?.[0]?.environmentId}`}>
.some(Boolean); <Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
{haveServicesWithDomains ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
size="sm"
variant="default">
<ExternalLinkIcon className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[200px] space-y-2 overflow-y-auto max-h-[400px]"
onClick={(e) => e.stopPropagation()}>
{project.environments.some(
(env) => env.applications.length > 0
) && (
<DropdownMenuGroup>
<DropdownMenuLabel>
Applications
</DropdownMenuLabel>
{project.environments.map((env) =>
env.applications.map((app) => (
<div key={app.applicationId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{app.name}
<StatusTooltip
status={
app.applicationStatus
}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{app.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${
domain.https
? "https"
: "http"
}://${domain.host}${
domain.path
}`}>
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
))
)}
</DropdownMenuGroup>
)}
{project.environments.some(
(env) => env.compose.length > 0
) && (
<DropdownMenuGroup>
<DropdownMenuLabel>
Compose
</DropdownMenuLabel>
{project.environments.map((env) =>
env.compose.map((comp) => (
<div key={comp.composeId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{comp.name}
<StatusTooltip
status={comp.composeStatus}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{comp.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${
domain.https
? "https"
: "http"
}://${domain.host}${
domain.path
}`}>
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
))
)}
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2">
<span className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<BookIcon className="size-4 text-muted-foreground" />
<span className="text-base font-medium leading-none">
{project.name}
</span>
</div>
return ( <span className="text-sm font-medium text-muted-foreground">
<div {project.description}
key={project.projectId} </span>
className="w-full lg:max-w-md" </span>
> <div className="flex self-start space-x-1">
<Link <DropdownMenu>
href={`/dashboard/project/${project.projectId}/environment/${project?.environments?.[0]?.environmentId}`} <DropdownMenuTrigger asChild>
> <Button
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border"> variant="ghost"
{haveServicesWithDomains ? ( size="icon"
<DropdownMenu> className="px-2">
<DropdownMenuTrigger asChild> <MoreHorizontalIcon className="size-5" />
<Button </Button>
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100" </DropdownMenuTrigger>
size="sm" <DropdownMenuContent
variant="default" className="w-[200px] space-y-2 overflow-y-auto max-h-[280px]"
> onClick={(e) => e.stopPropagation()}>
<ExternalLinkIcon className="size-3.5" /> <DropdownMenuLabel className="font-normal">
</Button> Actions
</DropdownMenuTrigger> </DropdownMenuLabel>
<DropdownMenuContent <div
className="w-[200px] space-y-2 overflow-y-auto max-h-[400px]" onClick={(e) => e.stopPropagation()}>
onClick={(e) => e.stopPropagation()} <ProjectEnvironment
> projectId={project.projectId}
{project.environments.some( />
(env) => env.applications.length > 0, </div>
) && ( <div
<DropdownMenuGroup> onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel> <HandleProject
Applications projectId={project.projectId}
</DropdownMenuLabel> />
{project.environments.map((env) => </div>
env.applications.map((app) => (
<div key={app.applicationId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{app.name}
<StatusTooltip
status={
app.applicationStatus
}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{app.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild
>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
)),
)}
</DropdownMenuGroup>
)}
{project.environments.some(
(env) => env.compose.length > 0,
) && (
<DropdownMenuGroup>
<DropdownMenuLabel>
Compose
</DropdownMenuLabel>
{project.environments.map((env) =>
env.compose.map((comp) => (
<div key={comp.composeId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{comp.name}
<StatusTooltip
status={comp.composeStatus}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{comp.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild
>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
)),
)}
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2">
<span className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<BookIcon className="size-4 text-muted-foreground" />
<span className="text-base font-medium leading-none">
{project.name}
</span>
</div>
<span className="text-sm font-medium text-muted-foreground"> <div
{project.description} onClick={(e) => e.stopPropagation()}>
</span> {(auth?.role === "owner" ||
</span> auth?.canDeleteProjects) && (
<div className="flex self-start space-x-1"> <AlertDialog>
<DropdownMenu> <AlertDialogTrigger className="w-full">
<DropdownMenuTrigger asChild> <DropdownMenuItem
<Button className="w-full cursor-pointer space-x-3"
variant="ghost" onSelect={(e) =>
size="icon" e.preventDefault()
className="px-2" }>
> <TrashIcon className="size-4" />
<MoreHorizontalIcon className="size-5" /> <span>Delete</span>
</Button> </DropdownMenuItem>
</DropdownMenuTrigger> </AlertDialogTrigger>
<DropdownMenuContent <AlertDialogContent>
className="w-[200px] space-y-2 overflow-y-auto max-h-[280px]" <AlertDialogHeader>
onClick={(e) => e.stopPropagation()} <AlertDialogTitle>
> Are you sure to delete this
<DropdownMenuLabel className="font-normal"> project?
Actions </AlertDialogTitle>
</DropdownMenuLabel> {!emptyServices ? (
<div <div className="flex flex-row gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
onClick={(e) => e.stopPropagation()} <AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
> <span className="text-sm text-yellow-600 dark:text-yellow-400">
<ProjectEnvironment You have active
projectId={project.projectId} services, please delete
/> them first
</div> </span>
<div </div>
onClick={(e) => e.stopPropagation()} ) : (
> <AlertDialogDescription>
<HandleProject This action cannot be
projectId={project.projectId} undone
/> </AlertDialogDescription>
</div> )}
</AlertDialogHeader>
<div <AlertDialogFooter>
onClick={(e) => e.stopPropagation()} <AlertDialogCancel>
> Cancel
{(auth?.role === "owner" || </AlertDialogCancel>
auth?.canDeleteProjects) && ( <AlertDialogAction
<AlertDialog> disabled={!emptyServices}
<AlertDialogTrigger className="w-full"> onClick={async () => {
<DropdownMenuItem await mutateAsync({
className="w-full cursor-pointer space-x-3" projectId:
onSelect={(e) => project.projectId,
e.preventDefault() })
} .then(() => {
> toast.success(
<TrashIcon className="size-4" /> "Project deleted successfully"
<span>Delete</span> );
</DropdownMenuItem> })
</AlertDialogTrigger> .catch(() => {
<AlertDialogContent> toast.error(
<AlertDialogHeader> "Error deleting this project"
<AlertDialogTitle> );
Are you sure to delete this })
project? .finally(() => {
</AlertDialogTitle> utils.project.all.invalidate();
{!emptyServices ? ( });
<div className="flex flex-row gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950"> }}>
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" /> Delete
<span className="text-sm text-yellow-600 dark:text-yellow-400"> </AlertDialogAction>
You have active </AlertDialogFooter>
services, please delete </AlertDialogContent>
them first </AlertDialog>
</span> )}
</div> </div>
) : ( </DropdownMenuContent>
<AlertDialogDescription> </DropdownMenu>
This action cannot be </div>
undone </CardTitle>
</AlertDialogDescription> </CardHeader>
)} <CardFooter className="pt-4">
</AlertDialogHeader> <div className="space-y-1 text-sm flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
<AlertDialogFooter> <DateTooltip date={project.createdAt}>
<AlertDialogCancel> Created
Cancel </DateTooltip>
</AlertDialogCancel> <span>
<AlertDialogAction {totalServices}{" "}
disabled={!emptyServices} {totalServices === 1
onClick={async () => { ? "service"
await mutateAsync({ : "services"}
projectId: </span>
project.projectId, </div>
}) </CardFooter>
.then(() => { </Card>
toast.success( </Link>
"Project deleted successfully", </div>
); );
}) })}
.catch(() => { </div>
toast.error( </>
"Error deleting this project", )}
); </CardContent>
}) </div>
.finally(() => { </Card>
utils.project.all.invalidate(); </div>
}); </>
}} );
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardTitle>
</CardHeader>
<CardFooter className="pt-4">
<div className="space-y-1 text-sm flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
<DateTooltip date={project.createdAt}>
Created
</DateTooltip>
<span>
{totalServices}{" "}
{totalServices === 1
? "service"
: "services"}
</span>
</div>
</CardFooter>
</Card>
</Link>
</div>
);
})}
</div>
</>
)}
</CardContent>
</div>
</Card>
</div>
</>
);
}; };
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/utils/api";
export function TimeBadge() {
const { data: serverTime } = api.server.getServerTime.useQuery(undefined, {
refetchInterval: 60000, // Refetch every 60 seconds
});
const [time, setTime] = useState<Date | null>(null);
useEffect(() => {
if (serverTime?.time) {
setTime(new Date(serverTime.time));
}
}, [serverTime]);
useEffect(() => {
const timer = setInterval(() => {
setTime((prevTime) => {
if (!prevTime) return null;
const newTime = new Date(prevTime.getTime() + 1000);
return newTime;
});
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
if (!time || !serverTime?.timezone) {
return null;
}
const getUtcOffset = (timeZone: string) => {
const date = new Date();
const utcDate = new Date(date.toLocaleString("en-US", { timeZone: "UTC" }));
const tzDate = new Date(date.toLocaleString("en-US", { timeZone }));
const offset = (tzDate.getTime() - utcDate.getTime()) / (1000 * 60 * 60);
const sign = offset >= 0 ? "+" : "-";
const hours = Math.floor(Math.abs(offset));
const minutes = (Math.abs(offset) * 60) % 60;
return `UTC${sign}${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}`;
};
return (
<div className="inline-flex items-center gap-2 rounded-md border px-2 py-1 text-xs sm:text-sm whitespace-nowrap max-w-full overflow-hidden">
<span className="hidden sm:inline">Server Time:</span>
<span className="font-medium tabular-nums">
{time.toLocaleTimeString()}
</span>
<span className="hidden sm:inline text-muted-foreground">
({serverTime.timezone} | {getUtcOffset(serverTime.timezone)})
</span>
</div>
);
}
@@ -383,6 +383,12 @@ export const serverRouter = createTRPCRouter({
const ip = await getPublicIpWithFallback(); const ip = await getPublicIpWithFallback();
return ip; return ip;
}), }),
getServerTime: protectedProcedure.query(() => {
return {
time: new Date(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
}),
getServerMetrics: protectedProcedure getServerMetrics: protectedProcedure
.input( .input(
z.object({ z.object({