Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96be14e1ba | ||
|
|
eec3c2737b | ||
|
|
a6b960ffb2 | ||
|
|
d3f465b5d4 | ||
|
|
2139dfd55d | ||
|
|
9f53181467 | ||
|
|
c96e13c72f | ||
|
|
58d9179152 | ||
|
|
17469696ee | ||
|
|
85047e7509 | ||
|
|
f9dc15ba73 | ||
|
|
7be542f676 | ||
|
|
847a567c52 | ||
|
|
1ffbd6efc8 | ||
|
|
f8f599e4dd | ||
|
|
f7cd169090 | ||
|
|
bb4003b0db |
@@ -1,4 +1,25 @@
|
||||
# Ignore Docker-related configs
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
README.md
|
||||
docker-compose*.yml
|
||||
|
||||
# Development/Editor config
|
||||
.idea
|
||||
.next
|
||||
.vscode
|
||||
|
||||
# Node-related
|
||||
node_modules
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
pnpm-debug.log
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
|
||||
|
||||
# Docs and markdowns
|
||||
README.md
|
||||
*.md
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
migrate:
|
||||
docker compose run --rm app
|
||||
CLUSTER_SCRIPT=docker/entrypoints/app-dev-entrypoint.sh
|
||||
|
||||
up:
|
||||
@bash $(CLUSTER_SCRIPT)
|
||||
@@ -1,7 +1,7 @@
|
||||
<br />
|
||||
<div align="center">
|
||||
<a href="https://portabase.io">
|
||||
<img src="images/logo.png" alt="Logo" width="80" height="80">
|
||||
<img src="/public/images/logo.png" alt="Logo" width="80" height="80">
|
||||
</a>
|
||||
|
||||
<h3 align="center">Portabase</h3>
|
||||
@@ -18,8 +18,12 @@
|
||||
·
|
||||
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
|
||||
</p>
|
||||
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/watch?v=D9uFrGxLc4s" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
## 📚 Table of Contents
|
||||
|
||||
- [About The Project](#about-the-project)
|
||||
|
||||
@@ -1,19 +1,57 @@
|
||||
import React from "react";
|
||||
"use client"
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {LayoutAdmin} from "@/components/layout";
|
||||
import Image from "next/image";
|
||||
import {env} from "@/env.mjs";
|
||||
import {useTheme} from "next-themes";
|
||||
import {useSession} from "@/lib/auth/auth-client";
|
||||
import {useRouter} from "next/navigation";
|
||||
|
||||
|
||||
export default async function Layout({children}: { children: React.ReactNode }) {
|
||||
export default function Layout({children}: { children: React.ReactNode }) {
|
||||
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
|
||||
|
||||
|
||||
if (session && session.user && !session.user.banned && session.user.role !== "pending") {
|
||||
router.replace("/dashboard/home");
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const imageTheme = resolvedTheme === "dark" ? "/images/logo-white.png" : "/images/logo-black.png";
|
||||
|
||||
return (
|
||||
<LayoutAdmin>
|
||||
<div
|
||||
className="w-full h-full grid "
|
||||
>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
{children}
|
||||
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 ">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md flex items-center justify-center space-x-2">
|
||||
<img
|
||||
className="p-12 text-black dark:text-white"
|
||||
src={imageTheme}
|
||||
// loading="eager"
|
||||
alt="Logo"
|
||||
// width={1024}
|
||||
// height={1024}
|
||||
/>
|
||||
<span
|
||||
className="text-sm text-muted-foreground -ml-12 -mb-12">
|
||||
v{env.NEXT_PUBLIC_PROJECT_VERSION}
|
||||
</span>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</LayoutAdmin>
|
||||
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground">
|
||||
Powered by <span className="font-medium">Soluce Technologies</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useRef} from "react";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {useSearchParams} from "next/navigation";
|
||||
|
||||
import {LoginForm} from "@/components/wrappers/auth/login/loginForm/LoginForm";
|
||||
import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form";
|
||||
import {toast} from "sonner";
|
||||
|
||||
export default function SignInPage(props: {
|
||||
searchParams: Promise<{ callbackUrl: string | undefined }>
|
||||
}) {
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const error = searchParams.get("error");
|
||||
const isFirstRender = useRef(true);
|
||||
const [urlParams, setUrlParams] = useState<URLSearchParams>();
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current && error) {
|
||||
toast.error("Error occurred.");
|
||||
isFirstRender.current = false;
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
setUrlParams(urlParams);
|
||||
const error = urlParams.get("error");
|
||||
console.log(urlParams.get("redirect"));
|
||||
if (error?.includes("pending")) {
|
||||
toast.error("Your account is not active.");
|
||||
urlParams.delete("error");
|
||||
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
|
||||
}
|
||||
}, [error]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid w-[350px] gap-6">
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<LoginForm/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import {RegisterForm} from "@/components/wrappers/auth/register/register-form/re
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
return (
|
||||
<div className="mx-auto grid w-[350px] gap-6">
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<RegisterForm/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { PageParams } from "@/types/next";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
||||
import { AdminTabs } from "@/components/wrappers/dashboard/admin/admin-tabs";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { db } from "@/db";
|
||||
import {eq, isNotNull, isNull} from "drizzle-orm";
|
||||
import {isNull} from "drizzle-orm";
|
||||
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
const user = await currentUser();
|
||||
|
||||
const users = await db.query.user.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt)
|
||||
});
|
||||
// const users = await db.query.user.findMany();
|
||||
console.log("users",users);
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
where: (fields, { eq }) => eq(fields.name, "system"),
|
||||
@@ -26,7 +20,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
<PageHeader>
|
||||
<PageTitle>Administration Panel</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent className="mt-10">
|
||||
<PageContent>
|
||||
<AdminTabs settings={settings!} users={users} />
|
||||
</PageContent>
|
||||
</Page>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PageParams } from "@/types/next";
|
||||
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
||||
import { AgentForm } from "@/components/wrappers/dashboard/agent/AgentForm/AgentForm";
|
||||
import { AgentForm } from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import Link from "next/link";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||
import {buttonVariants} from "@/components/ui/button";
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {DatabaseCard} from "@/components/wrappers/dashboard/projects/ProjectCard/ProjectDatabaseCard";
|
||||
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/AgentCardKey/AgentCardKey";
|
||||
import {DatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
|
||||
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {notFound} from "next/navigation";
|
||||
import {
|
||||
ButtonDeleteProject
|
||||
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
|
||||
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
|
||||
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
|
||||
@@ -24,8 +28,8 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
databases: true
|
||||
}
|
||||
})
|
||||
//
|
||||
console.log(agent)
|
||||
|
||||
|
||||
|
||||
if (!agent) {
|
||||
notFound()
|
||||
@@ -33,6 +37,13 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
//
|
||||
// const databaseId = 'db-123';
|
||||
//
|
||||
|
||||
console.log("agent",agent)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// const totalBackupsResult = await db
|
||||
// .select({ count: drizzleDb.schemas.backup.id })
|
||||
// .from(drizzleDb.schemas.backup)
|
||||
@@ -67,22 +78,19 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
href={`/dashboard/agents/${agent.id}/edit`}>
|
||||
<GearIcon className="w-7 h-7"/>
|
||||
</Link>
|
||||
{/*<AgentModalKey agent={agent}>*/}
|
||||
{/* <Button variant="outline">*/}
|
||||
{/* <KeyRound/>*/}
|
||||
{/* </Button>*/}
|
||||
{/*</AgentModalKey>*/}
|
||||
</PageTitle>
|
||||
|
||||
<PageActions className="justify-between">
|
||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"} />
|
||||
</PageActions>
|
||||
</div>
|
||||
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between gap-8 mb-6">
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="font-bold text-xl">
|
||||
Backups
|
||||
Databases
|
||||
</CardHeader>
|
||||
<CardContent></CardContent>
|
||||
<CardContent>{agent.databases.length}</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="font-bold text-xl">
|
||||
@@ -102,7 +110,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<Card className="w-full sm:w-auto flex-1 mb-4">
|
||||
<CardHeader className="font-bold text-xl">
|
||||
Edge Key
|
||||
</CardHeader>
|
||||
@@ -110,7 +118,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
<AgentCardKey agent={agent}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CardsWithPagination data={agent.databases} cardItem={DatabaseCard}/>
|
||||
<CardsWithPagination cardsPerPage={2} data={agent.databases} cardItem={DatabaseCard}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PageParams } from "@/types/next";
|
||||
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
||||
import { AgentForm } from "@/components/wrappers/dashboard/agent/AgentForm/AgentForm";
|
||||
import { AgentForm } from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
return (
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { PageParams } from "@/types/next";
|
||||
import { AgentCard } from "@/components/wrappers/dashboard/agent/AgentCard/AgentCard";
|
||||
import { AgentCard } from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
|
||||
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { Page, PageActions, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
import {and, eq, not} from "drizzle-orm";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
const agents = await db.query.agent.findMany();
|
||||
|
||||
const agents = await db.query.agent.findMany({
|
||||
where: not(eq(drizzleDb.schemas.agent.isArchived, true))
|
||||
});
|
||||
|
||||
|
||||
if (!agents) {
|
||||
notFound();
|
||||
@@ -27,7 +34,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
</PageActions>
|
||||
)}
|
||||
</PageHeader>
|
||||
<PageContent className="mt-10">
|
||||
<PageContent>
|
||||
{agents.length > 0 ? (
|
||||
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1} />
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
import {RestoreForm} from "@/components/wrappers/dashboard/database/RestoreForm";
|
||||
import {RestoreForm} from "@/components/wrappers/dashboard/database/restore-form";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
|
||||
import {db} from "@/db";
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { PageParams } from "@/types/next";
|
||||
|
||||
export default function RoutePage(props: PageParams<{}>) {
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4">
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="aspect-video rounded-xl bg-muted/50" />
|
||||
<div className="aspect-video rounded-xl bg-muted/50" />
|
||||
<div className="aspect-video rounded-xl bg-muted/50" />
|
||||
</div>
|
||||
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
|
||||
{JSON.stringify(activeOrganization)}
|
||||
{activeOrganization?.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PageParams } from "@/types/next";
|
||||
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
||||
import { notFound } from "next/navigation";
|
||||
import { DatabaseForm } from "@/components/wrappers/dashboard/database/DatabaseForm/DatabaseForm";
|
||||
import { DatabaseForm } from "@/components/wrappers/dashboard/database/database-form/database-form";
|
||||
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
@@ -2,10 +2,10 @@ import {PageParams} from "@/types/next";
|
||||
import {notFound} from "next/navigation";
|
||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
||||
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/Database/DatabaseTabs";
|
||||
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/Database/DatabaseKpi";
|
||||
import {EditButton} from "@/components/wrappers/dashboard/database/EditButton/EditButton";
|
||||
import {CronButton} from "@/components/wrappers/dashboard/database/CronButton/CronButton";
|
||||
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
|
||||
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
|
||||
import {EditButton} from "@/components/wrappers/dashboard/database/edit-button/edit-button";
|
||||
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
|
||||
|
||||
import {db} from "@/db";
|
||||
import {eq, and} from "drizzle-orm";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {notFound} from "next/navigation";
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {ProjectForm} from "@/components/wrappers/dashboard/projects/ProjectsForm/ProjectForm";
|
||||
import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
import {db} from "@/db";
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Page, PageActions, PageContent, PageDescription, PageTitle } from "@/fe
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { GearIcon } from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
import { ButtonDeleteProject } from "@/components/wrappers/dashboard/projects/ButtonDeleteProject/ButtonDeleteProject";
|
||||
import { ButtonDeleteProject } from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
|
||||
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
|
||||
import { ProjectDatabaseCard } from "@/components/wrappers/dashboard/projects/ProjectCard/ProjectDatabaseCard";
|
||||
import { ProjectDatabaseCard } from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { db } from "@/db";
|
||||
@@ -41,6 +41,7 @@ export default async function RoutePage(props: PageParams<{
|
||||
|
||||
if (!proj) notFound();
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
@@ -62,12 +63,18 @@ export default async function RoutePage(props: PageParams<{
|
||||
<CardsWithPagination
|
||||
data={proj.databases}
|
||||
organizationSlug={organization.slug}
|
||||
// @ts-ignore
|
||||
cardItem={ProjectDatabaseCard}
|
||||
cardsPerPage={4}
|
||||
numberOfColumns={1}
|
||||
extendedProps={proj}
|
||||
/>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
|
||||
<p className="text-lg font-medium">No databases found</p>
|
||||
<p className="text-sm mt-2">You haven’t added any databases to this project yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {ProjectForm} from "@/components/wrappers/dashboard/projects/ProjectsForm/ProjectForm";
|
||||
import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
|
||||
import {notFound} from "next/navigation";
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
|
||||
@@ -4,7 +4,7 @@ import {PageParams} from "@/types/next";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {ProjectCard} from "@/components/wrappers/dashboard/projects/ProjectCard/ProjectCard";
|
||||
import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card";
|
||||
import {db} from "@/db";
|
||||
import {notFound} from "next/navigation";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
@@ -41,7 +41,7 @@ export default async function RoutePage(props: PageParams<{ }>) {
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<PageContent className="mt-10">
|
||||
<PageContent>
|
||||
{projects.length > 0 ? (
|
||||
<CardsWithPagination organizationSlug={organization.slug} data={projects} cardItem={ProjectCard}
|
||||
cardsPerPage={4} numberOfColumns={1}/>
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
import {OrganizationForm} from "@/components/wrappers/dashboard/organization/OrganizationForm/OrganizationForm";
|
||||
import {OrganizationForm} from "@/components/wrappers/dashboard/organization/organization-form/organization-form";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
|
||||
import {db} from "@/db";
|
||||
import {isNull} from "drizzle-orm";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
|
||||
}>) {
|
||||
const user = await currentUser();
|
||||
|
||||
const organization = await getOrganization({});
|
||||
|
||||
if (!organization) {
|
||||
const users = await db.query.user.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt)
|
||||
});
|
||||
|
||||
|
||||
if (!user || !users || !organization || organization.slug == "default") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -22,7 +31,7 @@ export default async function RoutePage(props: PageParams<{
|
||||
</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<OrganizationForm members={organization.members} defaultValues={organization}/>
|
||||
<OrganizationForm currentUser={user} users={users} defaultValues={organization}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -1,42 +1,54 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageDescription, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {notFound} from "next/navigation";
|
||||
import {
|
||||
DeleteOrganizationButton
|
||||
} from "@/components/wrappers/dashboard/organization/DeleteOrganization/DeleteOrganizationButton";
|
||||
import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/EditButtonSettings/EditButtonSettings";
|
||||
} from "@/components/wrappers/dashboard/organization/delete-organization/delete-organization-button";
|
||||
import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-button-settings/edit-button-settings";
|
||||
import {
|
||||
SettingsOrganizationMembersTable
|
||||
} from "@/components/wrappers/dashboard/settings/settings-organization-members-table";
|
||||
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
const organization = await getOrganization({});
|
||||
const user = await currentUser();
|
||||
const activeMember = await getActiveMember()
|
||||
|
||||
|
||||
if (!organization) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isMember = activeMember?.role === "member";
|
||||
|
||||
if (isMember) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle className="flex items-center">
|
||||
Settings
|
||||
{organization.slug != "default" ?
|
||||
<EditButtonSettings/>
|
||||
: null}
|
||||
Organization settings
|
||||
{!isMember && organization.slug !== "default" && (
|
||||
<EditButtonSettings />
|
||||
)}
|
||||
</PageTitle>
|
||||
<PageActions>
|
||||
{organization.slug != "default" ?
|
||||
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
||||
: null}
|
||||
{!isMember && organization.slug !== "default" && (
|
||||
<DeleteOrganizationButton organizationSlug={organization.slug} />
|
||||
)}
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
<PageDescription>
|
||||
Manage your organization settings.
|
||||
</PageDescription>
|
||||
{/*<PageDescription>*/}
|
||||
{/* Manage your organization settings.*/}
|
||||
{/*</PageDescription>*/}
|
||||
<PageContent>
|
||||
{/* TODO add the list of organisation members (add, remove, edit) */}
|
||||
<SettingsOrganizationMembersTable organization={organization} />
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { PageParams } from "@/types/next";
|
||||
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EvolutionLineChart } from "@/components/wrappers/dashboard/statistics/charts/evolution-line-chart";
|
||||
import { PercentageLineChart } from "@/components/wrappers/dashboard/statistics/charts/percentage-line-chart";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
import { asc, count, eq, inArray } from "drizzle-orm";
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {EvolutionLineChart} from "@/components/wrappers/dashboard/statistics/charts/evolution-line-chart";
|
||||
import {PercentageLineChart} from "@/components/wrappers/dashboard/statistics/charts/percentage-line-chart";
|
||||
import {notFound} from "next/navigation";
|
||||
import {db} from "@/db";
|
||||
import {and, asc, count, eq, inArray} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
import {DatabaseBackup, Folder, RefreshCcw} from "lucide-react";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ }>) {
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
const organization = await getOrganization({});
|
||||
|
||||
if (!organization) {
|
||||
@@ -26,7 +27,13 @@ export default async function RoutePage(props: PageParams<{ }>) {
|
||||
where: eq(drizzleDb.schemas.project.organizationId, org.id),
|
||||
});
|
||||
|
||||
const projectsCount = projects.length;
|
||||
const projectIds = projects.map(project => project.id);
|
||||
|
||||
const databasesOfAllProjects = await db.query.database.findMany({
|
||||
where: inArray(drizzleDb.schemas.database.projectId, projectIds),
|
||||
})
|
||||
const databaseIds = databasesOfAllProjects.map((database) => database.id);
|
||||
|
||||
|
||||
const backupsEvolution = await db.query.backup.findMany({
|
||||
columns: {
|
||||
@@ -34,9 +41,11 @@ export default async function RoutePage(props: PageParams<{ }>) {
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: [asc(drizzleDb.schemas.backup.id)],
|
||||
where: inArray(drizzleDb.schemas.backup.databaseId, databaseIds),
|
||||
});
|
||||
|
||||
|
||||
|
||||
const backupsRate = await db
|
||||
.select({
|
||||
createdAt: drizzleDb.schemas.backup.createdAt,
|
||||
@@ -44,11 +53,24 @@ export default async function RoutePage(props: PageParams<{ }>) {
|
||||
_count: count(),
|
||||
})
|
||||
.from(drizzleDb.schemas.backup)
|
||||
.where(inArray(drizzleDb.schemas.backup.status, ["success", "failed"]))
|
||||
.where(and(inArray(drizzleDb.schemas.backup.status, ["success", "failed"]), inArray(drizzleDb.schemas.backup.databaseId, databaseIds)))
|
||||
.groupBy(drizzleDb.schemas.backup.createdAt, drizzleDb.schemas.backup.status)
|
||||
.orderBy(drizzleDb.schemas.backup.createdAt);
|
||||
|
||||
console.log(backupsRate);
|
||||
|
||||
|
||||
const restorationsCountResult = await db
|
||||
.select({
|
||||
count: count(),
|
||||
})
|
||||
.from(drizzleDb.schemas.restoration)
|
||||
.where(inArray(drizzleDb.schemas.restoration.databaseId, databaseIds));
|
||||
|
||||
|
||||
|
||||
const restorationsCount = restorationsCountResult[0]?.count ?? 0;
|
||||
const projectsCount = projects.length;
|
||||
const backupsEvolutionCount = backupsEvolution.length;
|
||||
|
||||
|
||||
return (
|
||||
@@ -58,23 +80,26 @@ export default async function RoutePage(props: PageParams<{ }>) {
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<Card className="w-full flex-1">
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<Folder className="w-5 h-5 text-muted-foreground" />
|
||||
<CardTitle>Projects</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>{projectsCount}</CardContent>
|
||||
<CardContent className="text-3xl font-bold">{projectsCount}</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>KPI 2</CardTitle>
|
||||
<Card className="w-full flex-1">
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<DatabaseBackup className="w-5 h-5 text-muted-foreground" />
|
||||
<CardTitle>Backups</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent></CardContent>
|
||||
<CardContent className="text-3xl font-bold">{backupsEvolutionCount}</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>KPI 3</CardTitle>
|
||||
<Card className="w-full flex-1">
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<RefreshCcw className="w-5 h-5 text-muted-foreground" />
|
||||
<CardTitle>Restorations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent></CardContent>
|
||||
<CardContent className="text-3xl font-bold">{restorationsCount}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
@@ -83,7 +108,7 @@ export default async function RoutePage(props: PageParams<{ }>) {
|
||||
<CardTitle>Evolution of the number of backups</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EvolutionLineChart data={backupsEvolution} />
|
||||
<EvolutionLineChart data={backupsEvolution}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full">
|
||||
@@ -91,7 +116,7 @@ export default async function RoutePage(props: PageParams<{ }>) {
|
||||
<CardTitle>Success rate of backups</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PercentageLineChart data={backupsRate} />
|
||||
<PercentageLineChart data={backupsRate}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {Building2, DatabaseBackup, Folder, RefreshCcw} from "lucide-react";
|
||||
import {EvolutionLineChart} from "@/components/wrappers/dashboard/statistics/charts/evolution-line-chart";
|
||||
import {PercentageLineChart} from "@/components/wrappers/dashboard/statistics/charts/percentage-line-chart";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {notFound} from "next/navigation";
|
||||
import {db} from "@/db";
|
||||
import {asc, eq, inArray} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {auth, listOrganizations} from "@/lib/auth/auth";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const user = await currentUser();
|
||||
const organizations = await listOrganizations()
|
||||
|
||||
if (!user || !organizations) notFound();
|
||||
|
||||
const organizationIds = organizations.map(project => project.id);
|
||||
|
||||
|
||||
const projects = await db.query.project.findMany({
|
||||
where: inArray(drizzleDb.schemas.project.organizationId, organizationIds),
|
||||
});
|
||||
|
||||
const projectIds = projects.map(project => project.id);
|
||||
|
||||
|
||||
const databasesOfAllProjects = await db.query.database.findMany({
|
||||
where: inArray(drizzleDb.schemas.database.projectId, projectIds),
|
||||
})
|
||||
const databaseIds = databasesOfAllProjects.map((database) => database.id);
|
||||
|
||||
|
||||
const backupsEvolution = await db.query.backup.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: [asc(drizzleDb.schemas.backup.id)],
|
||||
where: inArray(drizzleDb.schemas.backup.databaseId, databaseIds),
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Dashboard</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<Card className="w-full flex-1">
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-muted-foreground"/>
|
||||
<CardTitle>Organizations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold">{organizations.length}</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full flex-1">
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<Folder className="w-5 h-5 text-muted-foreground"/>
|
||||
<CardTitle>Projects</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold">{projects.length}</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full flex-1">
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<DatabaseBackup className="w-5 h-5 text-muted-foreground"/>
|
||||
<CardTitle>Backups</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold">{backupsEvolution.length}</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="aspect-video rounded-xl bg-muted/50"/>
|
||||
<div className="aspect-video rounded-xl bg-muted/50"/>
|
||||
<div className="aspect-video rounded-xl bg-muted/50"/>
|
||||
</div>
|
||||
</div>
|
||||
</PageContent>
|
||||
</Page>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import {redirect} from "next/navigation";
|
||||
|
||||
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar";
|
||||
import {AppSidebar} from "@/components/wrappers/dashboard/sideBar/app-sidebar";
|
||||
import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
|
||||
import {Header} from "@/features/layout/Header";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LoadingSpinner } from "@/components/wrappers/common/loading/LoadingSpinner";
|
||||
import { LoadingSpinner } from "@/components/wrappers/common/loading/loading-spinner";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {Page, PageContent, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
import {UserForm} from "@/components/wrappers/dashboard/profile/UserForm/UserForm";
|
||||
import {UserForm} from "@/components/wrappers/dashboard/profile/user-form/user-form";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {ButtonDeleteAccount} from "@/components/wrappers/dashboard/profile/ButtonDeleteAccount/ButtonDeleteAccount";
|
||||
import {AvatarWithUpload} from "@/components/wrappers/dashboard/profile/Avatar/AvatarWithUpload";
|
||||
import {ButtonDeleteAccount} from "@/components/wrappers/dashboard/profile/button-delete-account/button-delete-account";
|
||||
import {AvatarWithUpload} from "@/components/wrappers/dashboard/profile/avatar/avatar-with-upload";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {getAccounts, getSessions} from "@/lib/auth/auth";
|
||||
//import { getAccounts, getSessions } from "@/lib/auth/auth";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
const user = await currentUser();
|
||||
@@ -16,7 +14,6 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
|
||||
if (user.role !== "user" && user.role !== "admin" && user.role !== "superadmin") {
|
||||
return notFound();
|
||||
}
|
||||
@@ -42,9 +39,9 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
{user.name}
|
||||
<Badge className="ml-3 hidden lg:block">{user.role}</Badge>
|
||||
</PageTitle>
|
||||
<PageActions className="mt-2 hidden sm:block">
|
||||
<ButtonDeleteAccount text="Delete my account"/>
|
||||
</PageActions>
|
||||
{/*<PageActions className="mt-2 hidden sm:block">*/}
|
||||
{/* <ButtonDeleteAccount text="Delete my account"/>*/}
|
||||
{/*</PageActions>*/}
|
||||
</div>
|
||||
<PageContent>
|
||||
<UserForm
|
||||
|
||||
@@ -6,7 +6,7 @@ export default async function Index() {
|
||||
const user = await currentUser();
|
||||
if (user) {
|
||||
const currentOrganizationSlug = await getCurrentOrganizationSlug();
|
||||
redirect(`/dashboard/${currentOrganizationSlug}/projects`);
|
||||
redirect(`/dashboard/home`);
|
||||
}
|
||||
redirect("/login");
|
||||
//Do not delete
|
||||
|
||||
@@ -2,9 +2,17 @@ import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET({ params }: { params: Promise<{ fileName: string }> }) {
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{params}: { params: Promise<{ fileName: string }> }
|
||||
) {
|
||||
|
||||
try {
|
||||
const fileName = (await params).fileName;
|
||||
|
||||
|
||||
console.log("fileName", fileName);
|
||||
|
||||
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
|
||||
|
||||
// Check if the file exists
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function NotFound() {
|
||||
// redirect("/dashboard/profile");
|
||||
return(
|
||||
<>
|
||||
Error
|
||||
</>
|
||||
<div className="flex items-center min-h-screen px-4 py-12 sm:px-6 md:px-8 lg:px-12 xl:px-16">
|
||||
<div className="w-full space-y-6 text-center">
|
||||
<div className="space-y-3">
|
||||
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">Not found</h1>
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6">The content you are trying to view is not available.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ services:
|
||||
context: .
|
||||
dockerfile: docker/dockerfile/Dockerfile
|
||||
target: prod
|
||||
|
||||
ports:
|
||||
- '8887:80'
|
||||
env_file:
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
name: portabase-dev
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/dockerfile/Dockerfile
|
||||
target: dev
|
||||
ports:
|
||||
- "8887:80"
|
||||
environment:
|
||||
- TIME_ZONE="Europe/Paris"
|
||||
- NODE_ENV=development
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
container_name: portabase-app
|
||||
|
||||
develop:
|
||||
watch:
|
||||
- action: sync
|
||||
path: .
|
||||
target: /app
|
||||
- action: rebuild
|
||||
path: package.json
|
||||
# app:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: dev
|
||||
# ports:
|
||||
# - "8887:8887"
|
||||
# environment:
|
||||
# - TIME_ZONE="Europe/Paris"
|
||||
# - NODE_ENV=development
|
||||
# depends_on:
|
||||
# db:
|
||||
# condition: service_healthy
|
||||
# volumes:
|
||||
# - .:/app
|
||||
# - /app/node_modules
|
||||
# container_name: portabase-app
|
||||
#
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
@@ -43,35 +36,38 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
db-pgadmin:
|
||||
image: dpage/pgadmin4
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: "devuser@devuser.devuser"
|
||||
PGADMIN_DEFAULT_PASSWORD: "changeme"
|
||||
PGADMIN_CONFIG_SERVER_MODE: "False"
|
||||
POSTGRES_USER: "devuser"
|
||||
POSTGRES_PASSWORD: "changeme"
|
||||
volumes:
|
||||
- pgadmin-data:/var/lib/pgadmin
|
||||
ports:
|
||||
- "8080:80"
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- db
|
||||
# db-pgadmin:
|
||||
# image: dpage/pgadmin4
|
||||
# environment:
|
||||
# PGADMIN_DEFAULT_EMAIL: "devuser@devuser.devuser"
|
||||
# PGADMIN_DEFAULT_PASSWORD: "changeme"
|
||||
# PGADMIN_CONFIG_SERVER_MODE: "False"
|
||||
# POSTGRES_USER: "devuser"
|
||||
# POSTGRES_PASSWORD: "changeme"
|
||||
# volumes:
|
||||
# - pgadmin-data:/var/lib/pgadmin
|
||||
# ports:
|
||||
# - "8080:80"
|
||||
# restart: unless-stopped
|
||||
# depends_on:
|
||||
# - db
|
||||
|
||||
s3:
|
||||
container_name: s3-portabase-dev
|
||||
image: docker.io/bitnami/minio:latest
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
|
||||
environment:
|
||||
- MINIO_ROOT_USER=${S3_ACCESS_KEY}
|
||||
- MINIO_ROOT_PASSWORD=${S3_SECRET_KEY}
|
||||
- MINIO_DEFAULT_BUCKETS=${S3_BUCKET_NAME}
|
||||
- MINIO_BROWSER=on
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
minio_data:
|
||||
pgadmin-data:
|
||||
# pgadmin-data:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:23-alpine AS base
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
ENV YARN_VERSION=4.9.1
|
||||
|
||||
@@ -88,6 +88,7 @@ RUN chown -R nextjs:nodejs /app/public
|
||||
|
||||
|
||||
COPY --from=builder /app/next.config.ts ./
|
||||
COPY --from=builder /app/portabase.config.ts ./
|
||||
COPY --from=builder /app/drizzle.config.ts ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
#npx drizzle-kit generate
|
||||
#npx drizzle-kit migrate
|
||||
#
|
||||
#npm run dev
|
||||
#
|
||||
#exec "$@"
|
||||
set -euo pipefail
|
||||
|
||||
npm install -g npm@11.0.0
|
||||
npm -v
|
||||
|
||||
echo "▶ Running Drizzle codegen..."
|
||||
npx drizzle-kit generate
|
||||
|
||||
echo "▶ Applying migrations..."
|
||||
npx drizzle-kit migrate
|
||||
|
||||
npm run dev
|
||||
|
||||
exec "$@"
|
||||
echo "▶ Starting Next.js dev server..."
|
||||
exec npm run dev
|
||||
|
||||
@@ -3,9 +3,11 @@ import { loggingMiddleware } from "@/middleware/loggingMiddleware";
|
||||
import { errorHandler } from "@/middleware/errorHandler";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { signOut } from "@/lib/auth/auth-client";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const url = request.nextUrl.clone();
|
||||
const redirectUrl = encodeURIComponent(request.nextUrl.pathname)
|
||||
|
||||
if (url.pathname.startsWith("/dashboard")) {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -13,7 +15,21 @@ export async function middleware(request: NextRequest) {
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
return NextResponse.redirect(new URL(`/login?redirect=${redirectUrl}`, request.url));
|
||||
}
|
||||
|
||||
if (session.user.banned) {
|
||||
signOut();
|
||||
return NextResponse.redirect(new URL("/login?error=banned", request.url));
|
||||
}
|
||||
|
||||
if (session.user.role === "pending") {
|
||||
signOut();
|
||||
return NextResponse.redirect(new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url));
|
||||
}
|
||||
|
||||
if (url.pathname === "/dashboard") {
|
||||
return NextResponse.redirect(new URL(`/dashboard/home`, request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
// next-auth.d.ts
|
||||
import NextAuth from "next-auth";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
authMethod: string; // Add authMethod to the session user type
|
||||
};
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
authMethod: string; // Add authMethod to the user type
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,40 @@
|
||||
import type { NextConfig } from "next";
|
||||
import type {NextConfig} from "next";
|
||||
import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config";
|
||||
|
||||
|
||||
function buildCSPHeader(): string {
|
||||
const {CSP} = PORTABASE_DEFAULT_SETTINGS.SECURITY;
|
||||
|
||||
const directives = [
|
||||
`default-src ${CSP.DEFAULT_SRC.join(" ")}`,
|
||||
`script-src ${CSP.SCRIPT_SRC.join(" ")}`,
|
||||
`style-src ${CSP.STYLE_SRC.join(" ")}`,
|
||||
`img-src ${CSP.IMG_SRC.join(" ")}`,
|
||||
`font-src ${CSP.FONT_SRC.join(" ")}`,
|
||||
`object-src ${CSP.OBJECT_SRC.join(" ")}`,
|
||||
`connect-src ${CSP.CONNECT_SRC.join(" ")}`,
|
||||
`base-uri ${CSP.BASE_URI.join(" ")}`,
|
||||
`form-action ${CSP.FORM_ACTION.join(" ")}`,
|
||||
`frame-ancestors ${CSP.FRAME_ANCESTORS.join(" ")}`,
|
||||
];
|
||||
|
||||
if (CSP.BLOCK_ALL_MIXED_CONTENT) {
|
||||
directives.push("block-all-mixed-content");
|
||||
}
|
||||
|
||||
if (CSP.UPGRADE_INSECURE_REQUESTS) {
|
||||
directives.push("upgrade-insecure-requests");
|
||||
}
|
||||
|
||||
return directives.join("; ");
|
||||
}
|
||||
|
||||
function buildPermissionsPolicy(): string {
|
||||
return Object.entries(PORTABASE_DEFAULT_SETTINGS.SECURITY.PERMISSIONS_POLICY)
|
||||
.map(([feature, values]) => `${feature.toLowerCase()}=${values.join(", ")}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
@@ -14,6 +50,40 @@ const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
nodeMiddleware: true,
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: buildCSPHeader(),
|
||||
},
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: buildPermissionsPolicy(),
|
||||
},
|
||||
{
|
||||
key: 'X-Content-Type-Options',
|
||||
value: 'nosniff',
|
||||
},
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'DENY',
|
||||
},
|
||||
{
|
||||
key: 'Referrer-Policy',
|
||||
value: 'strict-origin-when-cross-origin',
|
||||
},
|
||||
{
|
||||
key: 'Strict-Transport-Security',
|
||||
value: 'max-age=63072000; includeSubDomains; preload',
|
||||
}
|
||||
// ...other security headers
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 80",
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
"build": "next build --experimental-build-mode compile",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
@@ -52,7 +52,7 @@
|
||||
"@zenstackhq/runtime": "2.14.2",
|
||||
"argon2": "^0.43.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "^1.2.8",
|
||||
"better-auth": "1.3.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -65,7 +65,7 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.510.0",
|
||||
"minio": "^8.0.5",
|
||||
"next": "^15.4.2-canary.5",
|
||||
"next": "15.4.2-canary.10",
|
||||
"next-safe-action": "^7.10.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^7.0.3",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
|
||||
export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
SECURITY: {
|
||||
CSP: {
|
||||
DEFAULT_SRC: ["'self'"],
|
||||
SCRIPT_SRC: [
|
||||
"'self'",
|
||||
"'unsafe-eval'",
|
||||
"'unsafe-inline'",
|
||||
"https://cdn.jsdelivr.net",
|
||||
"https://www.googletagmanager.com",
|
||||
"https://code.iconify.design",
|
||||
"https://code.iconify.com",
|
||||
"https://cdn.iconify.design",
|
||||
"https://api.iconify.design",
|
||||
],
|
||||
STYLE_SRC: [
|
||||
"'self'",
|
||||
"'unsafe-inline'",
|
||||
"https://fonts.googleapis.com",
|
||||
"https://code.iconify.design",
|
||||
"https://cdn.iconify.design",
|
||||
"https://code.iconify.com",
|
||||
],
|
||||
IMG_SRC: [
|
||||
"'self'",
|
||||
"blob:",
|
||||
"data:",
|
||||
"https:",
|
||||
"https://code.iconify.design",
|
||||
"https://cdn.iconify.design",
|
||||
"https://code.iconify.com",
|
||||
"https://api.iconify.design",
|
||||
],
|
||||
FONT_SRC: [
|
||||
"'self'",
|
||||
"https://fonts.gstatic.com",
|
||||
"https://cdn.iconify.design",
|
||||
],
|
||||
CONNECT_SRC: [
|
||||
"'self'",
|
||||
"https://api.iconify.design",
|
||||
"https://code.iconify.design",
|
||||
],
|
||||
OBJECT_SRC: ["'none'"],
|
||||
BASE_URI: ["'self'"],
|
||||
FORM_ACTION: ["'self'"],
|
||||
FRAME_ANCESTORS: ["'none'"],
|
||||
BLOCK_ALL_MIXED_CONTENT: false,
|
||||
UPGRADE_INSECURE_REQUESTS: true,
|
||||
},
|
||||
PERMISSIONS_POLICY: {
|
||||
CAMERA: ["()"],
|
||||
MICROPHONE: ["()"],
|
||||
GEOLOCATION: ["()"],
|
||||
FULLSCREEN: ["(self)"],
|
||||
// ...other features
|
||||
},
|
||||
},
|
||||
};
|
||||
|
Before Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 27 KiB |
@@ -9,9 +9,9 @@ import { toast } from "sonner";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import Link from "next/link";
|
||||
import { PasswordInput } from "@/components/wrappers/auth/PaswordInput/password-input";
|
||||
import { LoginSchema, LoginType } from "@/components/wrappers/auth/login/loginForm/login-form.schema";
|
||||
import { SocialAuthButton, SocialProviderType } from "@/components/wrappers/auth/login/buttonAuth/SocialAuthButton";
|
||||
import { PasswordInput } from "@/components/wrappers/auth/password-input/password-input";
|
||||
import { LoginSchema, LoginType } from "@/components/wrappers/auth/login/login-form/login-form.schema";
|
||||
import { SocialAuthButton, SocialProviderType } from "@/components/wrappers/auth/login/button-auth/social-auth-button";
|
||||
import { signIn } from "@/lib/auth/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Icon } from "@iconify/react";
|
||||
@@ -12,7 +12,7 @@ import { Form } from "@/components/ui/form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TooltipProvider, TooltipTrigger, Tooltip, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { RegisterSchema, RegisterType } from "@/components/wrappers/auth/register/register-form/register-form.schema";
|
||||
import { PasswordInput } from "@/components/wrappers/auth/PaswordInput/password-input";
|
||||
import { PasswordInput } from "@/components/wrappers/auth/password-input/password-input";
|
||||
import {signUp} from "@/lib/auth/auth-client";
|
||||
|
||||
export type registerFormProps = {
|
||||
|
||||
@@ -48,7 +48,7 @@ export function CardsWithPagination<T>(props: CardsWithPaginationProps<T>) {
|
||||
))}
|
||||
</div>
|
||||
<PaginationNavigation
|
||||
className="justify-end"
|
||||
className="justify-end mt-4"
|
||||
totalPages={totalPages}
|
||||
currentPage={currentPage}
|
||||
goToPage={goToPage}
|
||||
|
||||
@@ -7,11 +7,13 @@ export type ConnectionCircleProps = {
|
||||
export const ConnectionCircle = ({ date }: ConnectionCircleProps) => {
|
||||
let style = "bg-gray-300 border-gray-400";
|
||||
|
||||
if (date) {
|
||||
if (date instanceof Date && !isNaN(date.getTime())) {
|
||||
const now = Date.now();
|
||||
const timestamp = new Date(date).getTime();
|
||||
const timestamp = date.getTime();
|
||||
const interval = now - timestamp;
|
||||
|
||||
console.log({ now, timestamp, interval });
|
||||
|
||||
if (interval < 10000) {
|
||||
style = "bg-green-400 border-green-600";
|
||||
} else if (interval <= 20000) {
|
||||
@@ -19,7 +21,11 @@ export const ConnectionCircle = ({ date }: ConnectionCircleProps) => {
|
||||
} else {
|
||||
style = "bg-red-400 border-red-600";
|
||||
}
|
||||
} else {
|
||||
console.warn("Invalid date passed to ConnectionCircle:", date);
|
||||
}
|
||||
|
||||
console.log(style);
|
||||
|
||||
return <div className={cn("w-5 h-5 rounded-full border-4", style)} />;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,158 @@
|
||||
"use client";
|
||||
|
||||
// "use client";
|
||||
//
|
||||
// import {
|
||||
// ColumnDef,
|
||||
// flexRender,
|
||||
// getCoreRowModel,
|
||||
// useReactTable,
|
||||
// getPaginationRowModel,
|
||||
// getSortedRowModel,
|
||||
// SortingState,
|
||||
// ColumnFiltersState,
|
||||
// getFilteredRowModel,
|
||||
// } from "@tanstack/react-table";
|
||||
//
|
||||
// import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
// import { Input } from "@/components/ui/input";
|
||||
//
|
||||
// import { useState } from "react";
|
||||
// import { TablePagination } from "./table-pagination";
|
||||
// import { Checkbox } from "@/components/ui/checkbox";
|
||||
//
|
||||
// interface DataTableProps<TData, TValue> {
|
||||
// columns: ColumnDef<TData, TValue>[];
|
||||
// data: TData[];
|
||||
// enableFilter?: boolean;
|
||||
// enableSelect?: boolean;
|
||||
// enablePagination?: boolean;
|
||||
// paginationOptions?: {
|
||||
// pageSize: number[];
|
||||
// pageVisible: number;
|
||||
// className?: string;
|
||||
// };
|
||||
// filterOptions?: {
|
||||
// title?: string;
|
||||
// key: string;
|
||||
// };
|
||||
// emptyText?: string;
|
||||
// }
|
||||
//
|
||||
// export function DataTable<TData, TValue>({
|
||||
// columns,
|
||||
// data,
|
||||
// enableFilter = false,
|
||||
// enablePagination = true,
|
||||
// enableSelect = true,
|
||||
// paginationOptions = { pageSize: [10, 20, 30, 40, 50, 100], pageVisible: 3 },
|
||||
// filterOptions = { key: "id", title: "Filter by ID" },
|
||||
// emptyText = "No data.",
|
||||
// }: DataTableProps<TData, TValue>) {
|
||||
// const [sorting, setSorting] = useState<SortingState>([]);
|
||||
// const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
// const [rowSelection, setRowSelection] = useState({});
|
||||
//
|
||||
// if (enableSelect && data.length > 0) {
|
||||
// const selectColumnExists = columns.some((column) => column.id === "select");
|
||||
//
|
||||
// if (!selectColumnExists) {
|
||||
// columns.unshift({
|
||||
// id: "select",
|
||||
// header: ({ table }) => (
|
||||
// <Checkbox
|
||||
// checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
|
||||
// onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
// aria-label="Select all"
|
||||
// />
|
||||
// ),
|
||||
// cell: ({ row }) => <Checkbox checked={row.getIsSelected()} onCheckedChange={(value) => row.toggleSelected(!!value)} aria-label="Select row" />,
|
||||
// enableSorting: false,
|
||||
// enableHiding: false,
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// const table = useReactTable({
|
||||
// data,
|
||||
// columns,
|
||||
// getCoreRowModel: getCoreRowModel(),
|
||||
// getPaginationRowModel: getPaginationRowModel(),
|
||||
// onSortingChange: setSorting,
|
||||
// getSortedRowModel: getSortedRowModel(),
|
||||
// onColumnFiltersChange: setColumnFilters,
|
||||
// getFilteredRowModel: getFilteredRowModel(),
|
||||
// onRowSelectionChange: setRowSelection,
|
||||
// state: {
|
||||
// sorting,
|
||||
// columnFilters,
|
||||
// rowSelection,
|
||||
// },
|
||||
// });
|
||||
//
|
||||
// return (
|
||||
// <div className="h-full">
|
||||
// {enableFilter && (
|
||||
// <div className="flex items-center py-4">
|
||||
// <Input
|
||||
// placeholder={`${filterOptions.title ?? `Filter by ${filterOptions.key}`}`}
|
||||
// value={(table.getColumn(filterOptions.key)?.getFilterValue() as string) ?? ""}
|
||||
// onChange={(event) => table.getColumn(filterOptions.key)?.setFilterValue(event.target.value)}
|
||||
// className="max-w-sm"
|
||||
// />
|
||||
// </div>
|
||||
// )}
|
||||
// <div className="rounded-md border w-full">
|
||||
// <Table className="w-full">
|
||||
// <TableHeader>
|
||||
// {table.getHeaderGroups().map((headerGroup) => (
|
||||
// <TableRow key={headerGroup.id}>
|
||||
// {headerGroup.headers.map((header) => {
|
||||
// return (
|
||||
// <TableHead key={header.id}>
|
||||
// {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
// </TableHead>
|
||||
// );
|
||||
// })}
|
||||
// </TableRow>
|
||||
// ))}
|
||||
// </TableHeader>
|
||||
// <TableBody>
|
||||
// {table.getRowModel().rows?.length ? (
|
||||
// table.getRowModel().rows.map((row) => (
|
||||
// <TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
// {row.getVisibleCells().map((cell) => (
|
||||
// <TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
// ))}
|
||||
// </TableRow>
|
||||
// ))
|
||||
// ) : (
|
||||
// <TableRow>
|
||||
// <TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
// {emptyText}
|
||||
// </TableCell>
|
||||
// </TableRow>
|
||||
// )}
|
||||
// </TableBody>
|
||||
// </Table>
|
||||
// </div>
|
||||
// <div className="flex items-center justify-end space-x-2 py-4 mt-6">
|
||||
// {enableSelect && (
|
||||
// <div className="flex-1 text-sm text-muted-foreground">
|
||||
// {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
// </div>
|
||||
// )}
|
||||
// {enablePagination && (
|
||||
// <TablePagination
|
||||
// table={table}
|
||||
// maxVisiblePages={paginationOptions?.pageVisible}
|
||||
// pageSizeOptions={paginationOptions.pageSize}
|
||||
// className={paginationOptions.className}
|
||||
// />
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
"use client"
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
@@ -12,12 +165,14 @@ import {
|
||||
getFilteredRowModel,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
|
||||
import {Input} from "@/components/ui/input";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TablePagination } from "./table-pagination";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {useState} from "react";
|
||||
import {TablePagination} from "./table-pagination";
|
||||
import {Checkbox} from "@/components/ui/checkbox";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {useRouter} from "next/navigation";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@@ -34,37 +189,47 @@ interface DataTableProps<TData, TValue> {
|
||||
title?: string;
|
||||
key: string;
|
||||
};
|
||||
emptyText?: string;
|
||||
emptyButton?: {
|
||||
text: string;
|
||||
variant: "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | undefined;
|
||||
path: string;
|
||||
};
|
||||
highlightRow?: (row: TData) => boolean;
|
||||
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
enableFilter = false,
|
||||
enablePagination = true,
|
||||
enableSelect = true,
|
||||
paginationOptions = { pageSize: [10, 20, 30, 40, 50, 100], pageVisible: 3 },
|
||||
filterOptions = { key: "id", title: "Filter by ID" },
|
||||
emptyText = "No data.",
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
columns,
|
||||
data,
|
||||
enableFilter = false,
|
||||
enablePagination = true,
|
||||
enableSelect = true,
|
||||
paginationOptions = {pageSize: [10, 20, 30, 40, 50, 100], pageVisible: 3},
|
||||
filterOptions = {key: "id", title: "Filter by ID"},
|
||||
emptyButton,
|
||||
highlightRow
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
|
||||
|
||||
if (enableSelect && data.length > 0) {
|
||||
const selectColumnExists = columns.some((column) => column.id === "select");
|
||||
|
||||
if (!selectColumnExists) {
|
||||
columns.unshift({
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
header: ({table}) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => <Checkbox checked={row.getIsSelected()} onCheckedChange={(value) => row.toggleSelected(!!value)} aria-label="Select row" />,
|
||||
cell: ({row}) => <Checkbox checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"/>,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
});
|
||||
@@ -87,9 +252,11 @@ export function DataTable<TData, TValue>({
|
||||
rowSelection,
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
{enableFilter && (
|
||||
<div className="flex items-center py-4">
|
||||
<Input
|
||||
@@ -100,55 +267,86 @@ export function DataTable<TData, TValue>({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md border w-full">
|
||||
<Table className="w-full">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
))}
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
<div className="rounded-md border w-full">
|
||||
<Table className="w-full">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
{emptyText}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4 mt-6">
|
||||
{enableSelect && (
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
)}
|
||||
{enablePagination && (
|
||||
<TablePagination
|
||||
table={table}
|
||||
maxVisiblePages={paginationOptions?.pageVisible}
|
||||
pageSizeOptions={paginationOptions.pageSize}
|
||||
className={paginationOptions.className}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}
|
||||
className={highlightRow && highlightRow(row.original) ? "bg-gray-100 pointer-events-none" : ""}
|
||||
data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="p-0">
|
||||
{emptyButton ? (
|
||||
<Button
|
||||
variant={emptyButton.variant}
|
||||
className="btn btn-primary cursor-pointer w-full rounded-tr-none rounded-tl-none py-12 text-lg font-bold"
|
||||
onClick={() => {
|
||||
router.push(emptyButton.path)
|
||||
}}
|
||||
>
|
||||
<span>{emptyButton.text}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-12 text-lg font-bold">
|
||||
<span className="text-lg font-bold">No data available</span>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4 mt-6">
|
||||
{enableSelect && table.getFilteredRowModel().rows.length >= 1 && (
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
|
||||
selected.
|
||||
</div>
|
||||
)}
|
||||
{enablePagination && table.getFilteredRowModel().rows.length >= 1 && (
|
||||
<TablePagination
|
||||
table={table}
|
||||
maxVisiblePages={paginationOptions?.pageVisible}
|
||||
pageSizeOptions={(() => {
|
||||
const rowCount = table.getFilteredRowModel().rows.length;
|
||||
const allSizes = paginationOptions.pageSize.sort((a, b) => a - b);
|
||||
const validSizes = allSizes.filter((size) => size <= rowCount);
|
||||
const nextSize = allSizes.find((size) => size > rowCount);
|
||||
if (nextSize) validSizes.push(nextSize);
|
||||
return validSizes;
|
||||
})()}
|
||||
className={paginationOptions.className}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
import { userAction } from "@/safe-actions";
|
||||
import { z } from "zod";
|
||||
import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/email-form.schema";
|
||||
import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
@@ -8,9 +8,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
import { EmailFormSchema, EmailFormType } from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/email-form.schema";
|
||||
import { PasswordInput } from "@/components/wrappers/auth/PaswordInput/password-input";
|
||||
import { updateEmailSettingsAction } from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/email-form.action";
|
||||
import { EmailFormSchema, EmailFormType } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
||||
import { PasswordInput } from "@/components/wrappers/auth/password-input/password-input";
|
||||
import { updateEmailSettingsAction } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.action";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EmailForm } from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/EmailForm";
|
||||
import { EmailForm } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form";
|
||||
import { Send } from "lucide-react";
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Info, ShieldCheck } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { StorageS3Form } from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/StorageS3Form";
|
||||
import { useState } from "react";
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { checkConnexionToS3 } from "@/features/upload/public/upload.action";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateStorageSettingsAction } from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.action";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
|
||||
import {Info, ShieldCheck} from "lucide-react";
|
||||
import {Switch} from "@/components/ui/switch";
|
||||
import {Label} from "@/components/ui/label";
|
||||
import {StorageS3Form} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/storage-s3-form";
|
||||
import {useState} from "react";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {checkConnexionToS3} from "@/features/upload/public/upload.action";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {
|
||||
updateStorageSettingsAction
|
||||
} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.action";
|
||||
import {Setting} from "@/db/schema/00_setting";
|
||||
import {S3FormType} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema";
|
||||
|
||||
export type SettingsStorageTabProps = {
|
||||
settings: Setting;
|
||||
@@ -33,7 +36,7 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
|
||||
const [isSwitched, setIsSwitched] = useState<boolean>(props.settings.storage !== "local");
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => updateStorageSettingsAction({ name: "system", data: { storage: isSwitched ? "s3" : "local" } }),
|
||||
mutationFn: () => updateStorageSettingsAction({name: "system", data: {storage: isSwitched ? "s3" : "local"}}),
|
||||
onSuccess: () => {
|
||||
toast.success(`Settings updated successfully.`);
|
||||
router.refresh();
|
||||
@@ -48,14 +51,25 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
|
||||
await updateMutation.mutateAsync();
|
||||
};
|
||||
|
||||
const extractS3FormValues = (settings: Setting): S3FormType | undefined => {
|
||||
if (!settings.s3EndPointUrl) return undefined;
|
||||
return {
|
||||
s3EndPointUrl: settings.s3EndPointUrl,
|
||||
s3AccessKeyId: settings.s3AccessKeyId!,
|
||||
s3SecretAccessKey: settings.s3SecretAccessKey!,
|
||||
S3BucketName: settings.S3BucketName!,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full py-4">
|
||||
<h1>Settings for Portabase storage</h1>
|
||||
<Alert className="mt-3">
|
||||
<Info className="h-4 w-4" />
|
||||
<Info className="h-4 w-4"/>
|
||||
<AlertTitle>Informations</AlertTitle>
|
||||
<AlertDescription>
|
||||
Actually you can only store you data in one place : s3 compatible or in local. For exemple you cannot choose to store images in one place
|
||||
Actually you can only store you data in one place : s3 compatible or in local. For exemple you
|
||||
cannot choose to store images in one place
|
||||
and .dump files in another.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -79,14 +93,14 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
|
||||
onClick={async () => {
|
||||
await mutation.mutateAsync();
|
||||
}}
|
||||
icon={<ShieldCheck />}
|
||||
icon={<ShieldCheck/>}
|
||||
text="Test connexion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isSwitched && (
|
||||
<div className="mt-5">
|
||||
<StorageS3Form defaultValues={props.settings.s3EndPointUrl ? props.settings : null} />
|
||||
<StorageS3Form defaultValues={extractS3FormValues(props.settings)}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3,7 +3,7 @@
|
||||
import { userAction } from "@/safe-actions";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.schema";
|
||||
import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
@@ -8,10 +8,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.schema";
|
||||
import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.action";
|
||||
import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.action";
|
||||
|
||||
export type S3FormProps = {
|
||||
defaultValues?: S3FormType;
|
||||
@@ -1,30 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { SettingsEmailTab } from "@/components/wrappers/dashboard/admin/AdminEmailTab/SettingsEmailTab";
|
||||
import { SettingsStorageTab } from "@/components/wrappers/dashboard/admin/AdminStorageTab/SettingsStorageTab";
|
||||
import { SettingsEmailTab } from "@/components/wrappers/dashboard/admin/admin-email-tab/settings-email-tab";
|
||||
import { SettingsStorageTab } from "@/components/wrappers/dashboard/admin/admin-storage-tab/settings-storage-tab";
|
||||
import { AdminUsersTable } from "@/components/wrappers/dashboard/admin/admin-user-table";
|
||||
import { User } from "@/db/schema/01_user";
|
||||
import {Setting} from "@/db/schema/00_setting";
|
||||
import { Setting } from "@/db/schema/00_setting";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
export type AdminTabsProps = {
|
||||
users: User[];
|
||||
settings: Setting;
|
||||
};
|
||||
|
||||
export const AdminTabs = (props: AdminTabsProps) => {
|
||||
const { users, settings } = props;
|
||||
export const AdminTabs = ({ users, settings }: AdminTabsProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "users");
|
||||
|
||||
useEffect(() => {
|
||||
const newTab = searchParams.get("tab") ?? "users";
|
||||
setTab(newTab);
|
||||
}, [searchParams]);
|
||||
|
||||
const handleChangeTab = (value: string) => {
|
||||
router.push(`?tab=${value}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="users">
|
||||
<Tabs className="h-full" value={tab} onValueChange={handleChangeTab}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger className="w-full " value="users">
|
||||
<TabsTrigger className="w-full" value="users">
|
||||
Users
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="w-full " value="email">
|
||||
<TabsTrigger className="w-full" value="email">
|
||||
Email
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="w-full " value="storage">
|
||||
<TabsTrigger className="w-full" value="storage">
|
||||
Storage
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -13,7 +13,7 @@ export const AdminUsersTable = (props: AdminUsersTableProps) => {
|
||||
<div className="flex gap-4 h-fit justify-between">
|
||||
<h1>List of Portabase's users</h1>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<div className="mt-5 h-full">
|
||||
<DataTable columns={usersColumnsAdmin} data={users} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client"
|
||||
import {ColumnDef} from "@tanstack/react-table";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {updateUserAction} from "@/components/wrappers/dashboard/profile/UserForm/user-form.action";
|
||||
import {updateUserAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useState} from "react";
|
||||
import {Trash2} from "lucide-react";
|
||||
import {deleteUserAction} from "@/components/wrappers/dashboard/profile/ButtonDeleteAccount/delete-account.action";
|
||||
import {deleteUserAction} from "@/components/wrappers/dashboard/profile/button-delete-account/delete-account.action";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {User} from "@/db/schema/01_user";
|
||||
import {useSession} from "@/lib/auth/auth-client";
|
||||
import {authClient, useSession} from "@/lib/auth/auth-client";
|
||||
import {formatFrenchDate} from "@/utils/date-formatting";
|
||||
|
||||
export const usersColumnsAdmin: ColumnDef<User>[] = [
|
||||
@@ -20,6 +20,13 @@ export const usersColumnsAdmin: ColumnDef<User>[] = [
|
||||
cell: ({row}) => {
|
||||
const [role, setRole] = useState<string>(row.getValue("role"));
|
||||
|
||||
|
||||
const {data: session, isPending, error} = authClient.useSession();
|
||||
|
||||
|
||||
|
||||
const isCurrentUser = session?.user.email === row.original.email;
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => updateUserAction({id: row.original.id, data: {role: role}}),
|
||||
onSuccess: () => {
|
||||
@@ -36,8 +43,21 @@ export const usersColumnsAdmin: ColumnDef<User>[] = [
|
||||
await updateMutation.mutateAsync();
|
||||
};
|
||||
|
||||
|
||||
if (isPending) return null;
|
||||
|
||||
if (error || !session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Badge className="cursor-pointer" onClick={() => handleUpdateRole()} variant="outline">
|
||||
<Badge
|
||||
className={isCurrentUser ? "cursor-not-allowed opacity-50" : "cursor-pointer"}
|
||||
onClick={isCurrentUser ? undefined : () => handleUpdateRole()}
|
||||
variant="outline"
|
||||
>
|
||||
{role}
|
||||
</Badge>
|
||||
);
|
||||
@@ -73,23 +93,18 @@ export const usersColumnsAdmin: ColumnDef<User>[] = [
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{!session || session?.user.email === row.original.email ? null :
|
||||
<ButtonWithLoading
|
||||
disabled={!session || session?.user.email === row.original.email}
|
||||
variant="outline"
|
||||
text=""
|
||||
icon={<Trash2 color="red" size={15}/>}
|
||||
onClick={async () => {
|
||||
await mutation.mutateAsync();
|
||||
}}
|
||||
size="icon"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"use server";
|
||||
import { ActionError, userAction } from "@/safe-actions";
|
||||
import { AgentSchema } from "@/components/wrappers/dashboard/agent/AgentForm/agent-form.schema";
|
||||
import { z } from "zod";
|
||||
import { eq, and, ne, count } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
const verifySlugUniqueness = async (slug: string, agentId?: string) => {
|
||||
const conditions = agentId ? and(eq(drizzleDb.schemas.agent.slug, slug), ne(drizzleDb.schemas.agent.id, agentId)) : eq(drizzleDb.schemas.agent.slug, slug);
|
||||
|
||||
const [countResult] = await db.select({ count: count() }).from(drizzleDb.schemas.agent).where(conditions);
|
||||
|
||||
if (countResult.count > 0) {
|
||||
throw new ActionError("Slug already exists");
|
||||
}
|
||||
};
|
||||
|
||||
export const createAgentAction = userAction.schema(AgentSchema).action(async ({ parsedInput }) => {
|
||||
await verifySlugUniqueness(parsedInput.slug);
|
||||
|
||||
const [createdAgent] = await db.insert(drizzleDb.schemas.agent).values(parsedInput).returning();
|
||||
|
||||
return {
|
||||
data: createdAgent,
|
||||
};
|
||||
});
|
||||
|
||||
export const updateAgentAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
data: AgentSchema,
|
||||
})
|
||||
)
|
||||
.action(async ({ parsedInput }) => {
|
||||
await verifySlugUniqueness(parsedInput.data.slug, parsedInput.id);
|
||||
|
||||
const [updatedAgent] = await db.update(drizzleDb.schemas.agent).set(parsedInput.data).where(eq(drizzleDb.schemas.agent.id, parsedInput.id)).returning();
|
||||
|
||||
return {
|
||||
data: updatedAgent,
|
||||
};
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import {generateEdgeKey} from "@/utils/edge_key";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {PasswordInput} from "@/components/wrappers/auth/PaswordInput/password-input";
|
||||
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||
import {useState} from "react";
|
||||
import {CopyButton} from "@/components/wrappers/common/button/copy-button";
|
||||
import {Agent} from "@/db/schema/07_agent";
|
||||
@@ -14,13 +14,13 @@ export const AgentCard = (props: agentCardProps) => {
|
||||
const { data: agent } = props;
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/agents/${agent.id}`}>
|
||||
<Link href={`/dashboard/agents/${agent.id}`} className="block transition-all duration-200 hover:scale-[1.01] hover:shadow-md">
|
||||
<Card className="flex flex-row justify-between">
|
||||
<div className="">
|
||||
<CardHeader>{agent.name}</CardHeader>
|
||||
<CardContent>Last contact : {formatDateLastContact(agent.lastContact)}</CardContent>
|
||||
<div className="flex-1 text-left">
|
||||
<CardHeader className="text-2xl font-bold">{agent.name}</CardHeader>
|
||||
<CardContent>Last contact: {formatDateLastContact(agent.lastContact)}</CardContent>
|
||||
</div>
|
||||
<div className="mt-3 mr-3">
|
||||
<div className="flex items-center px-4">
|
||||
<ConnectionCircle date={agent.lastContact} />
|
||||
</div>
|
||||
</Card>
|
||||
@@ -0,0 +1,48 @@
|
||||
"use server";
|
||||
import {ActionError, userAction} from "@/safe-actions";
|
||||
import {AgentSchema} from "@/components/wrappers/dashboard/agent/agent-form/agent-form.schema";
|
||||
import {z} from "zod";
|
||||
import {eq, and, ne, count} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {slugify} from "@/utils/slugify";
|
||||
|
||||
const verifySlugUniqueness = async (slug: string, agentId?: string) => {
|
||||
const conditions = agentId ? and(eq(drizzleDb.schemas.agent.slug, slug), ne(drizzleDb.schemas.agent.id, agentId)) : eq(drizzleDb.schemas.agent.slug, slug);
|
||||
|
||||
const [countResult] = await db.select({count: count()}).from(drizzleDb.schemas.agent).where(conditions);
|
||||
|
||||
if (countResult.count > 0) {
|
||||
throw new ActionError("Slug already exists");
|
||||
}
|
||||
};
|
||||
|
||||
export const createAgentAction = userAction.schema(AgentSchema).action(async ({parsedInput}) => {
|
||||
const slug = slugify(parsedInput.name);
|
||||
await verifySlugUniqueness(slug);
|
||||
const [createdAgent] = await db.insert(drizzleDb.schemas.agent).values({...parsedInput, slug: slug}).returning();
|
||||
return {
|
||||
data: createdAgent,
|
||||
};
|
||||
});
|
||||
|
||||
export const updateAgentAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
data: AgentSchema,
|
||||
})
|
||||
)
|
||||
.action(async ({parsedInput}) => {
|
||||
const slug = slugify(parsedInput.data.name);
|
||||
await verifySlugUniqueness(slug, parsedInput.id);
|
||||
|
||||
const [updatedAgent] = await db.update(drizzleDb.schemas.agent).set({
|
||||
...parsedInput.data,
|
||||
slug: slug
|
||||
}).where(eq(drizzleDb.schemas.agent.id, parsedInput.id)).returning();
|
||||
|
||||
return {
|
||||
data: updatedAgent,
|
||||
};
|
||||
});
|
||||
@@ -2,11 +2,6 @@ import { z } from "zod";
|
||||
|
||||
export const AgentSchema = z.object({
|
||||
name: z.string(),
|
||||
slug: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9_-]*$/)
|
||||
.min(5)
|
||||
.max(25),
|
||||
description: z.string(),
|
||||
});
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { AgentSchema, AgentType } from "@/components/wrappers/dashboard/agent/AgentForm/agent-form.schema";
|
||||
import { toast } from "sonner";
|
||||
import { createAgentAction, updateAgentAction } from "@/components/wrappers/dashboard/agent/AgentForm/agent-form.action";
|
||||
import {Card, CardContent} from "@/components/ui/card";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useZodForm
|
||||
} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Form} from "@/components/ui/form";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {AgentSchema, AgentType} from "@/components/wrappers/dashboard/agent/agent-form/agent-form.schema";
|
||||
import {toast} from "sonner";
|
||||
import {createAgentAction, updateAgentAction} from "@/components/wrappers/dashboard/agent/agent-form/agent-form.action";
|
||||
|
||||
export type agentFormProps = {
|
||||
defaultValues?: AgentType;
|
||||
@@ -29,14 +37,13 @@ export const AgentForm = (props: agentFormProps) => {
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: AgentType) => {
|
||||
console.log("values", values);
|
||||
|
||||
const createAgent = isCreate
|
||||
? await createAgentAction(values)
|
||||
: await updateAgentAction({
|
||||
id: props.agentId ?? "-",
|
||||
data: values,
|
||||
});
|
||||
id: props.agentId ?? "-",
|
||||
data: values,
|
||||
});
|
||||
|
||||
const data = createAgent?.data?.data;
|
||||
if (createAgent?.serverError || !data) {
|
||||
@@ -44,7 +51,7 @@ export const AgentForm = (props: agentFormProps) => {
|
||||
toast.error(createAgent?.serverError);
|
||||
return;
|
||||
}
|
||||
toast.success(`Success`);
|
||||
toast.success(`Success ${isCreate ? "creating" : "updating"} agent`);
|
||||
router.push(`/dashboard/agents/${data.id}`);
|
||||
router.refresh();
|
||||
},
|
||||
@@ -65,37 +72,14 @@ export const AgentForm = (props: agentFormProps) => {
|
||||
control={form.control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Agent 1" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Your agent project name</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
defaultValue=""
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
//value={field.value ?? ""}
|
||||
placeholder="agent-1"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replaceAll(" ", "-").toLowerCase();
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>The slug is used in the url of the agent</FormDescription>
|
||||
<FormMessage />
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -103,14 +87,15 @@ export const AgentForm = (props: agentFormProps) => {
|
||||
control={form.control}
|
||||
defaultValue=""
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="This agent is for the client exemple.com" {...field} value={field.value ?? ""} />
|
||||
<Input placeholder="This agent is for the client exemple.com" {...field}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormDescription>Enter your project agent description</FormDescription>
|
||||
<FormMessage />
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -7,7 +7,7 @@ import { generateEdgeKey } from "@/utils/edge_key";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { CopyButton } from "@/components/wrappers/common/button/copy-button";
|
||||
import { getServerUrl } from "@/utils/get-server-url";
|
||||
import { CodeSnippet } from "@/components/wrappers/code-snippet/CodeSnippet";
|
||||
import { CodeSnippet } from "@/components/wrappers/code-snippet/code-snippet";
|
||||
import {Agent} from "@/db/schema/07_agent";
|
||||
|
||||
export type agentRegistrationDialogProps = PropsWithChildren<{
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import {deleteAgentAction} from "@/components/wrappers/dashboard/agent/button-delete-agent/delete-agent.action";
|
||||
|
||||
export type ButtonDeleteAgentProps = {
|
||||
text?: string;
|
||||
agentId: string;
|
||||
};
|
||||
|
||||
export const ButtonDeleteAgent = (props: ButtonDeleteAgentProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => deleteAgentAction(props.agentId),
|
||||
onSuccess: async (result: any) => {
|
||||
if (result.data?.success) {
|
||||
toast.success(result.data.actionSuccess.message);
|
||||
router.push("/dashboard/agents");
|
||||
} else {
|
||||
toast.error(result.data.actionError.message || "Unknown error occurred.");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ButtonWithConfirm
|
||||
text={props.text ? props.text : ""}
|
||||
onClick={() => {
|
||||
mutation.mutate();
|
||||
}}
|
||||
variant={"destructive"}
|
||||
isPending={mutation.isPending}
|
||||
className="gap-2"
|
||||
icon={<Trash2 />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
"use server";
|
||||
|
||||
import {userAction} from "@/safe-actions";
|
||||
import {z} from "zod";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {Agent} from "@/db/schema/07_agent";
|
||||
|
||||
export const deleteAgentAction = userAction.schema(z.string()).action(async ({parsedInput}): Promise<ServerActionResult<Agent>> => {
|
||||
try {
|
||||
|
||||
// const deletedAgent: Agent[] = await db.delete(drizzleDb.schemas.agent).where(eq(drizzleDb.schemas.agent.id, parsedInput)).returning();
|
||||
|
||||
const uuid = uuidv4();
|
||||
|
||||
const updatedAgent = await db
|
||||
.update(drizzleDb.schemas.agent)
|
||||
.set({
|
||||
isArchived: true,
|
||||
slug: uuid,
|
||||
})
|
||||
.where(eq(drizzleDb.schemas.agent.id, parsedInput))
|
||||
.returning();
|
||||
|
||||
|
||||
if (!updatedAgent[0]) {
|
||||
throw new Error("Agent not found or update failed");
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: updatedAgent[0],
|
||||
actionSuccess: {
|
||||
message: "Agent has been successfully deleted.",
|
||||
messageParams: {projectId: parsedInput},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to delete agent.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {projectId: parsedInput},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { SidebarMenuButton } from "@/components/ui/sidebar";
|
||||
import { ChevronUp } from "lucide-react";
|
||||
import { LoggedInDropdown } from "@/components/wrappers/dashboard/loggedInDropdown/LoggedInDropdown";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import {LoggedInDropdown} from "@/components/wrappers/dashboard/common/logged-in/logged-in-dropdown";
|
||||
|
||||
export const LoggedInButton = async () => {
|
||||
const user = await currentUser();
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem
|
||||
} from "@/components/ui/sidebar";
|
||||
import {SidebarLogo} from "@/components/wrappers/dashboard/common/sidebar/logo-sidebar";
|
||||
import {SidebarMenuCustomMain} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar-main";
|
||||
import {SideBarFooterCredit} from "@/components/wrappers/dashboard/common/sidebar/side-bar-footer-credit";
|
||||
import {OrganizationCombobox} from "@/components/wrappers/dashboard/organization/organization-combobox";
|
||||
import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button";
|
||||
|
||||
export function AppSidebar() {
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarLogo/>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<OrganizationCombobox/>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarMenuCustomMain/>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarMenu className="mb-2">
|
||||
<SidebarMenuItem>
|
||||
<LoggedInButton />
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<SideBarFooterCredit />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import Link from "next/link";
|
||||
import { env } from "@/env.mjs";
|
||||
import { useTheme } from "next-themes";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const SidebarLogo = () => {
|
||||
const { state, isMobile } = useSidebar();
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const imageTheme = resolvedTheme === "dark" ? "/images/logo-white.png" : "/images/logo-black.png";
|
||||
|
||||
return (
|
||||
<div className="ml-1 flex items-center justify-center">
|
||||
<Link href={"/"}>
|
||||
{state === 'collapsed' && !isMobile ? (
|
||||
<Image
|
||||
loading="eager"
|
||||
src={"/images/logo.png"}
|
||||
alt={`Logo ${env.NEXT_PUBLIC_PROJECT_NAME}`}
|
||||
className="h-10 w-10 object-contain"
|
||||
height={10}
|
||||
width={10}
|
||||
/>
|
||||
) : (
|
||||
<div className="m-4">
|
||||
<Image
|
||||
loading="eager"
|
||||
src={imageTheme}
|
||||
alt={`Logo ${env.NEXT_PUBLIC_PROJECT_NAME}`}
|
||||
className="object-contain"
|
||||
width={190}
|
||||
height={90}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
import {Home, Settings, Target, Users, Pickaxe, Euro, BanknoteX, Layers, ChartArea, ShieldHalf} from "lucide-react";
|
||||
import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar";
|
||||
import {authClient, useSession} from "@/lib/auth/auth-client";
|
||||
|
||||
|
||||
export const SidebarMenuCustomMain = () => {
|
||||
|
||||
const BASE_URL = `/dashboard`;
|
||||
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
const { data: organizations } = authClient.useListOrganizations();
|
||||
const {data: session, isPending, error} = authClient.useSession();
|
||||
const member = authClient.useActiveMember();
|
||||
|
||||
|
||||
|
||||
if (isPending) return null;
|
||||
|
||||
if (error || !session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupContentApplication: SidebarGroupItem["group_content"] = [
|
||||
{ title: "Dashboard", url: "/home", icon: Home },
|
||||
];
|
||||
|
||||
const groupContent: SidebarGroupItem["group_content"] = [
|
||||
{ title: "Projects", url: "/projects", icon: Layers, details:true },
|
||||
{ title: "Statistics", url: "/statistics", icon: ChartArea },
|
||||
];
|
||||
|
||||
if (activeOrganization && (member?.data?.role === "admin" || member?.data?.role === "owner")) {
|
||||
groupContent.push({ title: "Settings", url: "/settings", icon: Settings, details:true });
|
||||
}
|
||||
|
||||
const items: SidebarGroupItem[] = [
|
||||
{
|
||||
label: "Application",
|
||||
type: "list",
|
||||
group_content: groupContentApplication,
|
||||
},
|
||||
{
|
||||
label: "Organization",
|
||||
type: "list",
|
||||
group_content: groupContent,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
if (session?.user.role == "admin" || session?.user.role == "superadmin") {
|
||||
items.push({
|
||||
label: "Administration",
|
||||
type: "list",
|
||||
group_content: [
|
||||
{title: "Agents", url: "/agents", icon: ShieldHalf, details: true},
|
||||
{title: "Administration panel", url: "/admin", icon: Settings, details: true},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return <SidebarMenuCustomBase baseUrl={BASE_URL} items={items}/>;
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
ChevronDown,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from "@radix-ui/react-collapsible";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export type SidebarItem = {
|
||||
title: string;
|
||||
url: string;
|
||||
redirect?: boolean;
|
||||
not_from_base_url?: boolean;
|
||||
icon: React.ElementType;
|
||||
dropdown?: SidebarItem[];
|
||||
submenu?: SidebarItem[];
|
||||
details?: boolean;
|
||||
};
|
||||
|
||||
export type SidebarGroupItem = {
|
||||
label: string;
|
||||
type: "list" | "collapse";
|
||||
group_content: SidebarItem[];
|
||||
};
|
||||
|
||||
type SidebarMenuCustomBaseProps = {
|
||||
baseUrl: string;
|
||||
items: SidebarGroupItem[];
|
||||
};
|
||||
|
||||
export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseProps) => {
|
||||
const pathname = usePathname();
|
||||
const [activeItem, setActiveItem] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const normalize = (url: string) => url.split("?")[0].replace(/\/$/, "");
|
||||
|
||||
const findActiveUrl = () => {
|
||||
const normalizedPathname = normalize(pathname);
|
||||
console.log(normalizedPathname);
|
||||
for (const group of items) {
|
||||
for (const item of group.group_content) {
|
||||
const itemUrl = normalize(item.url);
|
||||
|
||||
const fullUrl = item.not_from_base_url
|
||||
? itemUrl
|
||||
: normalize(`${baseUrl}${itemUrl}`);
|
||||
|
||||
if (
|
||||
normalizedPathname === fullUrl ||
|
||||
(item.details && normalizedPathname.startsWith(`${fullUrl}/`))
|
||||
) {
|
||||
return itemUrl;
|
||||
}
|
||||
|
||||
if (item.submenu) {
|
||||
for (const subItem of item.submenu) {
|
||||
const subItemUrl = normalize(subItem.url);
|
||||
const subFullUrl = normalize(`${baseUrl}${subItemUrl}`);
|
||||
|
||||
if (
|
||||
normalizedPathname === subFullUrl ||
|
||||
(subItem.details && normalizedPathname.startsWith(`${subFullUrl}/`))
|
||||
) {
|
||||
return subItemUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const activeUrl = findActiveUrl();
|
||||
setActiveItem(activeUrl);
|
||||
}, [pathname, baseUrl, items]);
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
{items.map((group, index) => (
|
||||
<Collapsible key={index} defaultOpen disabled={group.type === "list"}>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger>
|
||||
{group.label ?? "Missing label"}
|
||||
{group.type === "collapse" && (
|
||||
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
{group.group_content.map((item, index) => (
|
||||
<SidebarMenuItem key={index} className="mb-1">
|
||||
<SidebarMenuButton asChild>
|
||||
<Link
|
||||
href={
|
||||
item.redirect || item.not_from_base_url
|
||||
? item.url
|
||||
: `${baseUrl}${item.url}`
|
||||
}
|
||||
target={item.redirect ? "_blank" : ""}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: "lg",
|
||||
variant: activeItem === item.url ? "secondary" : "ghost"
|
||||
}),
|
||||
"justify-start p-0",
|
||||
"hover:bg-gray-700"
|
||||
)}
|
||||
onClick={() => setActiveItem(item.url)}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
|
||||
{item.submenu && (
|
||||
<SidebarMenuSub>
|
||||
{item.submenu.map((sub, idx) => (
|
||||
<SidebarMenuSubItem key={idx}>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<Link
|
||||
href={`${baseUrl}${sub.url}`}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: "lg",
|
||||
variant: activeItem === sub.url ? "secondary" : "ghost"
|
||||
}),
|
||||
"justify-start p-0",
|
||||
"hover:bg-gray-700"
|
||||
)}
|
||||
onClick={() => setActiveItem(sub.url)}
|
||||
>
|
||||
<sub.icon />
|
||||
{sub.title}
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
)}
|
||||
|
||||
{item.dropdown && (
|
||||
<SidebarMenuAction>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<MoreHorizontal />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start">
|
||||
{item.dropdown.map((dropdown, idx) => (
|
||||
<DropdownMenuItem key={idx} asChild>
|
||||
<Link
|
||||
href={
|
||||
dropdown.redirect || dropdown.not_from_base_url
|
||||
? dropdown.url
|
||||
: `${baseUrl}${dropdown.url}`
|
||||
}
|
||||
className="justify-start p-0"
|
||||
target={dropdown.redirect ? "_blank" : ""}
|
||||
onClick={() => setActiveItem(dropdown.url)}
|
||||
>
|
||||
<span>{dropdown.title}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuAction>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,7 @@ export const SideBarFooterCredit = (props: SideBarFooterCreditProps) => {
|
||||
return (
|
||||
<>
|
||||
{state === "expanded" && (
|
||||
<div className="text-center">
|
||||
<div className="text-center mb-2">
|
||||
<h1 className="text-[10px]">Portabase Community Edition v{env.NEXT_PUBLIC_PROJECT_VERSION}</h1>
|
||||
</div>
|
||||
)}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
import {useSidebar} from "@/components/ui/sidebar";
|
||||
import {useTheme} from "next-themes";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export type SideBarLogoProps = {};
|
||||
|
||||
export const SideBarLogo = (props: SideBarLogoProps) => {
|
||||
const {state, isMobile} = useSidebar();
|
||||
const {resolvedTheme} = useTheme();
|
||||
return state === "collapsed" && !isMobile ? (
|
||||
<Link href={"/dashboard"}>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<Image
|
||||
src={"/images/logo/picto.png"}
|
||||
alt="Logo"
|
||||
width={32}
|
||||
height={32}
|
||||
loading="eager"
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={"/dashboard"}>
|
||||
|
||||
<div className="w-full h-auto p-4 flex items-center justify-center">
|
||||
<Image
|
||||
loading="eager"
|
||||
src={"/images/logo/logo.png"}
|
||||
alt="Logo"
|
||||
width={190}
|
||||
height={190}
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Clock9 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { CronInput } from "@/components/wrappers/dashboard/database/CronButton/CronInput";
|
||||
import { CronInput } from "@/components/wrappers/dashboard/database/cron-button/cron-input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -10,7 +10,7 @@ import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/CronButton/cron.action";
|
||||
import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/cron-button/cron.action";
|
||||
import {Database} from "@/db/schema/06_database";
|
||||
|
||||
export type CronButtonProps = {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AdvancedCronSelect } from "./AdvancedCronSelect";
|
||||
import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/CronButton/cron.action";
|
||||
import { AdvancedCronSelect } from "./advanced-cron-select";
|
||||
import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/cron-button/cron.action";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -8,8 +8,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { DatabaseSchema, DatabaseType } from "@/components/wrappers/dashboard/database/DatabaseForm/form-database.schema";
|
||||
import { updateDatabaseAction } from "@/components/wrappers/dashboard/database/DatabaseForm/form-database.action";
|
||||
import { DatabaseSchema, DatabaseType } from "@/components/wrappers/dashboard/database/database-form/form-database.schema";
|
||||
import { updateDatabaseAction } from "@/components/wrappers/dashboard/database/database-form/form-database.action";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export type DatabaseFormProps = {
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
||||
import { userAction } from "@/safe-actions";
|
||||
import { db } from "@/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { DatabaseSchema } from "@/components/wrappers/dashboard/database/DatabaseForm/form-database.schema";
|
||||
import { DatabaseSchema } from "@/components/wrappers/dashboard/database/database-form/form-database.schema";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
export const updateDatabaseAction = userAction
|
||||
@@ -11,6 +11,7 @@ import {createOrganizationAction} from "@/components/wrappers/dashboard/organiza
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useState} from "react";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {toast} from "sonner";
|
||||
|
||||
export type createOrganizationModalProps = {
|
||||
open: boolean;
|
||||
@@ -35,15 +36,19 @@ export function CreateOrganizationModal({open, onOpenChange, onSuccess}: createO
|
||||
|
||||
const result = await createOrganizationAction(values);
|
||||
|
||||
if (result && result.data) {
|
||||
if (result.data.success && result.data.value) {
|
||||
onOpenChange(false);
|
||||
await authClient.organization.setActive({organizationSlug: result.data.value.slug});
|
||||
onSuccess?.();
|
||||
router.replace(`/dashboard/home`);
|
||||
// router.refresh();
|
||||
}
|
||||
if (result?.data?.success && result.data.value) {
|
||||
onOpenChange(false);
|
||||
await authClient.organization.setActive({organizationSlug: result.data.value.slug});
|
||||
onSuccess?.();
|
||||
toast.success(result.data.actionSuccess?.message || "Organization Created.");
|
||||
// router.push("/");
|
||||
router.replace(`/dashboard/home`);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to create the organization.";
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
@@ -77,26 +82,26 @@ export function CreateOrganizationModal({open, onOpenChange, onSuccess}: createO
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replaceAll(" ", "-").toLowerCase();
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/*<FormField*/}
|
||||
{/* control={form.control}*/}
|
||||
{/* name="slug"*/}
|
||||
{/* defaultValue=""*/}
|
||||
{/* render={({field}) => (*/}
|
||||
{/* <FormItem>*/}
|
||||
{/* <FormLabel>Slug</FormLabel>*/}
|
||||
{/* <FormControl>*/}
|
||||
{/* <Input*/}
|
||||
{/* {...field}*/}
|
||||
{/* onChange={(e) => {*/}
|
||||
{/* const value = e.target.value.replaceAll(" ", "-").toLowerCase();*/}
|
||||
{/* field.onChange(value);*/}
|
||||
{/* }}*/}
|
||||
{/* />*/}
|
||||
{/* </FormControl>*/}
|
||||
{/* <FormMessage/>*/}
|
||||
{/* </FormItem>*/}
|
||||
{/* )}*/}
|
||||
{/*/>*/}
|
||||
<DialogFooter>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<Button type="submit">Create</Button>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { setCurrentOrganizationSlug } from "@/features/dashboard/organization-cookie";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
|
||||
export type DeleteOrganizationButtonProps = {
|
||||
organizationSlug: string;
|
||||
@@ -14,15 +15,26 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
|
||||
const router = useRouter();
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => deleteOrganizationAction(props.organizationSlug),
|
||||
|
||||
onSuccess: async (result) => {
|
||||
if (result.data.success) {
|
||||
await setCurrentOrganizationSlug("default");
|
||||
if (result?.data?.success) {
|
||||
await authClient.organization.setActive({
|
||||
organizationSlug: "default",
|
||||
});
|
||||
router.push("/");
|
||||
toast.success(result.data.actionSuccess.message);
|
||||
|
||||
toast.success(result.data.actionSuccess?.message || "Organization deleted.");
|
||||
} else {
|
||||
toast.error(result.data.actionError.message);
|
||||
// @ts-ignore
|
||||
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to delete the organization.";
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
},
|
||||
|
||||
onError: (error: any) => {
|
||||
console.error("Mutation network error:", error);
|
||||
toast.error(error?.message || "A network error occurred.");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -4,6 +4,8 @@ import {ComboBox} from "@/components/wrappers/common/combobox";
|
||||
import {useSidebar} from "@/components/ui/sidebar";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
|
||||
|
||||
export function OrganizationCombobox() {
|
||||
const router = useRouter();
|
||||
@@ -24,11 +26,15 @@ export function OrganizationCombobox() {
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
|
||||
const onValueChange = async (slug: string) => {
|
||||
|
||||
await authClient.organization.setActive({
|
||||
organizationSlug: slug,
|
||||
});
|
||||
router.refresh();
|
||||
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ export const OrganizationFormSchema = z.object({
|
||||
.regex(/^[a-zA-Z0-9_-]*$/, 'Slug can only contain letters, numbers, underscores, and hyphens')
|
||||
.min(5, 'Slug must be at least 5 characters long')
|
||||
.max(20, 'Slug must be at most 20 characters long'),
|
||||
members: z.array(z.string()),
|
||||
users: z.array(z.string()),
|
||||
|
||||
});
|
||||
|
||||
@@ -1,42 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MultiSelect } from "@/components/wrappers/common/multiselect/multi-select";
|
||||
import { OrganizationFormSchema, OrganizationFormType } from "@/components/wrappers/dashboard/organization/OrganizationForm/organization-form.schema";
|
||||
import { updateOrganizationAction } from "@/components/wrappers/dashboard/organization/organization.action";
|
||||
import { toast } from "sonner";
|
||||
import {Member, Organization} from "better-auth/plugins";
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useZodForm
|
||||
} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Form} from "@/components/ui/form";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
|
||||
import {
|
||||
OrganizationFormSchema,
|
||||
OrganizationFormType
|
||||
} from "@/components/wrappers/dashboard/organization/organization-form/organization-form.schema";
|
||||
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/02_organization";
|
||||
import {
|
||||
deleteOrganizationAction,
|
||||
updateOrganizationAction
|
||||
} from "@/components/wrappers/dashboard/organization/organization.action";
|
||||
import {toast} from "sonner";
|
||||
import {User as BetterAuthUser} from "better-auth";
|
||||
import {User} from "@/db/schema/01_user";
|
||||
|
||||
export type organizationFormProps = {
|
||||
defaultValues?: Organization;
|
||||
members: Member[];
|
||||
defaultValues?: OrganizationWithMembers;
|
||||
users: User[];
|
||||
currentUser: BetterAuthUser;
|
||||
};
|
||||
|
||||
export const OrganizationForm = (props: organizationFormProps) => {
|
||||
|
||||
const router = useRouter();
|
||||
const isCreate = !Boolean(props.defaultValues);
|
||||
|
||||
const formatUsersList = (members: Member[]) => {
|
||||
return members.map((member) => ({
|
||||
value: member.id,
|
||||
label: `${member.user.name} | ${member.user.email}`,
|
||||
}));
|
||||
const formatUsersList = (users: User[]) => {
|
||||
|
||||
return users
|
||||
.filter((user) => user.id !== props.currentUser.id)
|
||||
.map((user) => ({
|
||||
value: user.id,
|
||||
label: `${user.name} | ${user.email}`,
|
||||
}));
|
||||
};
|
||||
|
||||
const formatDefaultUsers = (members: OrganizationFormType["members"]): string[] => {
|
||||
console.log(members);
|
||||
return members.map((member) => member.userId);
|
||||
const formatDefaultUsers = (members: MemberWithUser[]): string[] => {
|
||||
return members
|
||||
.filter((member) => member.userId !== props.currentUser.id)
|
||||
.map((member) => member.userId);
|
||||
};
|
||||
|
||||
const formattedDefaultValues = {
|
||||
...props.defaultValues,
|
||||
users: !isCreate ? formatDefaultUsers(props.defaultValues?.members) : [],
|
||||
name: props.defaultValues?.name,
|
||||
slug: props.defaultValues?.slug,
|
||||
users: !isCreate ? formatDefaultUsers(props.defaultValues?.members as MemberWithUser[]) : [],
|
||||
};
|
||||
|
||||
const form = useZodForm({
|
||||
@@ -45,20 +68,29 @@ export const OrganizationForm = (props: organizationFormProps) => {
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: OrganizationFormType) => {
|
||||
console.log(values);
|
||||
// const organization = await updateOrganizationAction({ data: values, organizationId: props.defaultValues.id });
|
||||
// console.log(organization);
|
||||
// if (organization.data.success) {
|
||||
// // toast.success(organization.data.actionSuccess.message);
|
||||
// // router.push(`/dashboard/${organization.data.value.slug}/settings`);
|
||||
// // router.refresh();
|
||||
// } else {
|
||||
// // toast.success(organization.data.actionError.message);
|
||||
// }
|
||||
mutationFn: (values: OrganizationFormType) => updateOrganizationAction({
|
||||
data: values,
|
||||
organizationId: props.defaultValues?.id ?? ""
|
||||
}),
|
||||
onSuccess: async (result) => {
|
||||
if (result?.data?.success) {
|
||||
toast.success(result.data.actionSuccess?.message || "Organization updated successfully.");
|
||||
router.push("/dashboard/settings");
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to update the organization.";
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("Mutation network error:", error);
|
||||
toast.error(error?.message || "A network error occurred.");
|
||||
},
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader></CardHeader>
|
||||
@@ -74,13 +106,13 @@ export const OrganizationForm = (props: organizationFormProps) => {
|
||||
control={form.control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Organization 1" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -88,7 +120,7 @@ export const OrganizationForm = (props: organizationFormProps) => {
|
||||
control={form.control}
|
||||
name="slug"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
@@ -101,29 +133,29 @@ export const OrganizationForm = (props: organizationFormProps) => {
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Databases</FormLabel>
|
||||
<FormLabel>Users</FormLabel>
|
||||
<FormControl>
|
||||
<MultiSelect
|
||||
options={formatUsersList(props.members)}
|
||||
options={formatUsersList(props.users)}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value ?? []}
|
||||
placeholder="Select databases"
|
||||
placeholder="Select users"
|
||||
variant="inverted"
|
||||
animation={2}
|
||||
// maxCount={100}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Select users you want to add to this organization</FormDescription>
|
||||
<FormMessage />
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||