Merge branch 'main' into dev

This commit is contained in:
charlesgauthereau
2026-02-13 14:31:03 +01:00
46 changed files with 1508 additions and 1193 deletions
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier same "printed page" as the copyright notice for easier
identification within third-party archives. identification within third-party archives.
Copyright 2024 Soluce Technologies Copyright 2024 Portabase
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
+1 -1
View File
@@ -16,7 +16,7 @@ export default async function GuardPage() {
return ( return (
<TooltipProvider> <TooltipProvider>
<CardAuth className="w-full max-w-md"> <CardAuth className="w-full">
<CardHeader> <CardHeader>
<div className="grid gap-2 text-center mb-2"> <div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Two-factor verification</h1> <h1 className="text-3xl font-bold">Two-factor verification</h1>
+13 -6
View File
@@ -2,6 +2,8 @@ import React from "react";
import {redirect} from "next/navigation"; import {redirect} from "next/navigation";
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section"; import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section";
import {env} from "@/env.mjs";
import {Heart} from "lucide-react";
export default async function Layout({children}: { children: React.ReactNode }) { export default async function Layout({children}: { children: React.ReactNode }) {
@@ -12,13 +14,18 @@ export default async function Layout({children}: { children: React.ReactNode })
} }
return ( return (
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 "> <div className="flex min-h-screen flex-col justify-between py-10 sm:px-6 lg:px-8 ">
<div className="mx-auto w-full max-w-md"> <div className="flex flex-col items-center justify-center flex-1">
<AuthLogoSection/> <div className="mx-auto w-full max-w-md">
<div>{children}</div> <AuthLogoSection/>
<div className="mt-4">{children}</div>
</div>
</div> </div>
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground"> <footer className="mt-8 text-center text-xs text-muted-foreground flex flex-col gap-1">
Powered by <span className="font-medium">Soluce Technologies</span> <p className="flex items-center justify-center gap-1">
Made with <Heart className="size-3 fill-red-500 text-red-500" /> by <span className="font-medium text-foreground">Portabase</span>
</p>
<p>v{env.NEXT_PUBLIC_PROJECT_VERSION}</p>
</footer> </footer>
</div> </div>
) )
+4 -3
View File
@@ -1,9 +1,10 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { CardContent, CardHeader } from "@/components/ui/card";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form"; import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/auth"; import { auth } from "@/lib/auth/auth";
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar"; import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
import {CardAuth} from "@/features/layout/card-auth";
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) { export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
@@ -23,7 +24,7 @@ export default async function RoutePage(props: { searchParams: Promise<{ token:
return ( return (
<TooltipProvider> <TooltipProvider>
<Card className="w-full max-w-md shadow-lg"> <CardAuth className="w-full">
<CardHeader className="space-y-4"> <CardHeader className="space-y-4">
<div className="space-y-1 text-center"> <div className="space-y-1 text-center">
<h1 className="text-2xl font-bold tracking-tight">Set a new password</h1> <h1 className="text-2xl font-bold tracking-tight">Set a new password</h1>
@@ -53,7 +54,7 @@ export default async function RoutePage(props: { searchParams: Promise<{ token:
<CardContent> <CardContent>
<ResetPasswordForm /> <ResetPasswordForm />
</CardContent> </CardContent>
</Card> </CardAuth>
</TooltipProvider> </TooltipProvider>
); );
} }
@@ -6,8 +6,6 @@ import {eq, and, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {getOrganizationProjectDatabases} from "@/lib/services"; import {getOrganizationProjectDatabases} from "@/lib/services";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context"; import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content"; import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content";
@@ -85,12 +83,6 @@ export default async function RoutePage(props: PageParams<{
notFound(); notFound();
} }
const organizationChannels = await getOrganizationChannels(organization.id);
const activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled);
const organizationStorageChannels = await getOrganizationStorageChannels(organization.id);
const activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
@@ -110,8 +102,8 @@ export default async function RoutePage(props: PageParams<{
availableBackups={availableBackups} availableBackups={availableBackups}
successRate={successRate} successRate={successRate}
organizationId={organization.id} organizationId={organization.id}
activeOrganizationChannels={activeOrganizationChannels} activeOrganizationChannels={[]}
activeOrganizationStorageChannels={activeOrganizationStorageChannels} activeOrganizationStorageChannels={[]}
/> />
</BackupModalProvider> </BackupModalProvider>
</Page> </Page>
@@ -66,7 +66,7 @@ export default async function RoutePage(props: PageParams<{}>) {
organizationSlug={organization.slug} organizationSlug={organization.slug}
data={projects} data={projects}
cardItem={ProjectCard} cardItem={ProjectCard}
cardsPerPage={6} cardsPerPage={9}
numberOfColumns={3} numberOfColumns={3}
/> />
) : isMember ? ( ) : isMember ? (
+9
View File
@@ -145,4 +145,13 @@
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
@apply font-title; @apply font-title;
} }
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
} }
-1
View File
@@ -6,7 +6,6 @@ import {ThemeProvider} from "@/features/theme/theme-provider";
import {Toaster} from "@/components/ui/sonner"; import {Toaster} from "@/components/ui/sonner";
import {QueryClient, QueryClientProvider} from "@tanstack/react-query"; import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root"; import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
export type ProviderProps = PropsWithChildren<{}>; export type ProviderProps = PropsWithChildren<{}>;
+77 -77
View File
@@ -14,52 +14,52 @@
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts" "auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.10", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.13", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.6", "@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.9", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.1", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.10", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.14", "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.13", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.13", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.6", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.14", "@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.12", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.13", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.6", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.6", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.8", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.4", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.6", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.4", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.11", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.13", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.8", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.9", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.8",
"@react-email/components": "^0.0.41", "@react-email/components": "^0.0.41",
"@t3-oss/env-nextjs": "^0.13.4", "@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.76.1", "@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.22",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2", "@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.0", "argon2": "^0.43.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-auth": "1.4.5", "better-auth": "1.4.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dockerode": "^4.0.6", "dockerode": "^4.0.9",
"dotenv": "^16.5.0", "dotenv": "^16.6.1",
"drizzle-orm": "^0.43.1", "drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.7.1", "drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
@@ -67,64 +67,64 @@
"googleapis": "^170.1.0", "googleapis": "^170.1.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"minio": "^8.0.5", "minio": "^8.0.6",
"motion": "^12.23.24", "motion": "^12.34.0",
"next": "16.1.5", "next": "16.1.5",
"next-safe-action": "^7.10.8", "next-safe-action": "^7.10.8",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-forge": "^1.3.1", "node-forge": "^1.3.3",
"nodemailer": "^7.0.3", "nodemailer": "^7.0.13",
"npm-check-updates": "^18.0.1", "npm-check-updates": "^18.3.1",
"pg": "^8.16.0", "pg": "^8.18.0",
"prettier": "^3.8.0", "prettier": "^3.8.1",
"react": "^19.2.0", "react": "^19.2.4",
"react-day-picker": "9.7.0", "react-day-picker": "9.7.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.4",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.4.1",
"react-email": "^4.0.13", "react-email": "^4.3.2",
"react-hook-form": "^7.56.3", "react-hook-form": "^7.71.1",
"react-qr-code": "^2.0.18", "react-qr-code": "^2.0.18",
"react-resizable-panels": "^3.0.2", "react-resizable-panels": "^3.0.6",
"react-twc": "^1.4.2", "react-twc": "^1.5.1",
"react-use-measure": "^2.1.7", "react-use-measure": "^2.1.7",
"recharts": "^2.15.3", "recharts": "^2.15.4",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"socket.io": "^4.8.1", "socket.io": "^4.8.3",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.3",
"sonner": "^2.0.3", "sonner": "^2.0.7",
"swiper": "^12.0.3", "swiper": "^12.1.0",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.4.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"ws": "^8.18.2", "ws": "^8.19.0",
"zod": "^3.24.4" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@iconify/react": "^6.0.0", "@iconify/react": "^6.0.2",
"@react-email/preview-server": "4.3.2", "@react-email/preview-server": "4.3.2",
"@react-email/render": "^2.0.1", "@react-email/render": "^2.0.4",
"@tailwindcss/postcss": "^4.1.7", "@tailwindcss/postcss": "^4.1.18",
"@types/eslint-plugin-tailwindcss": "^3.17.0", "@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.15.18", "@types/node": "^22.19.11",
"@types/node-forge": "^1", "@types/node-forge": "^1.3.14",
"@types/pg": "^8.15.2", "@types/pg": "^8.16.0",
"@types/react": "^19.1.4", "@types/react": "^19.2.14",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.2.3",
"@zenstackhq/openapi": "^2.14.2", "@zenstackhq/openapi": "^2.22.1",
"@zenstackhq/tanstack-query": "^2.14.2", "@zenstackhq/tanstack-query": "^2.22.2",
"baseline-browser-mapping": "^2.9.19", "baseline-browser-mapping": "^2.9.19",
"drizzle-kit": "^0.31.1", "drizzle-kit": "^0.31.9",
"esbuild": "^0.27.2", "esbuild": "^0.27.3",
"eslint": "^9.39.0", "eslint": "^9.39.2",
"eslint-config-next": "^16.0.1", "eslint-config-next": "^16.1.6",
"eslint-plugin-tailwindcss": "^3.18.0", "eslint-plugin-tailwindcss": "^3.18.2",
"framer-motion": "^12.24.7", "framer-motion": "^12.34.0",
"postcss": "^8.5.3", "postcss": "^8.5.6",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.18",
"tsx": "^4.19.4", "tsx": "^4.21.0",
"tw-animate-css": "^1.2.9", "tw-animate-css": "^1.4.0",
"typescript": "^5.8.3", "typescript": "^5.9.3",
"zenstack": "2.14.2" "zenstack": "2.14.2"
}, },
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8" "packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
+923 -861
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -7,7 +7,7 @@ import { motion, useInView, type SpringOptions, type UseInViewOptions } from 'mo
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const githubButtonVariants = cva( const githubButtonVariants = cva(
'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu transition-transform duration-200 ease-out hover:scale-105 group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0', 'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
{ {
variants: { variants: {
variant: { variant: {
+1 -1
View File
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex w-full min-w-0 rounded-md border bg-transparent px-4 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className
+1 -1
View File
@@ -374,7 +374,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content" data-slot="sidebar-content"
data-sidebar="content" data-sidebar="content"
className={cn( className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", "flex min-h-0 flex-1 flex-col gap-2 overflow-auto scrollbar-hide group-data-[collapsible=icon]:overflow-hidden",
className className
)} )}
{...props} {...props}
@@ -41,9 +41,6 @@ export const AuthLogoSection = () => {
/> />
)} )}
<span className="absolute bottom-8 right-5 text-sm text-muted-foreground" style={style}>
v{env.NEXT_PUBLIC_PROJECT_VERSION}
</span>
</div> </div>
); );
}; };
@@ -68,7 +68,7 @@ export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => {
/> />
<div className="flex flex-col items-center gap-y-6 w-full"> <div className="flex flex-col items-center gap-y-6 w-full">
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}> <ButtonWithLoading className="mt-2 w-full h-11" isPending={mutation.isPending}>
Send reset link Send reset link
</ButtonWithLoading> </ButtonWithLoading>
<Link href="/login" className="group flex items-center text-sm hover:underline"> <Link href="/login" className="group flex items-center text-sm hover:underline">
@@ -113,7 +113,7 @@ export const LoginForm = (props: loginFormProps) => {
</FormItem> </FormItem>
)} )}
/> />
<ButtonWithLoading className="mt-2" isPending={mutation.isPending}> <ButtonWithLoading className="mt-2 h-11" isPending={mutation.isPending}>
Login Login
</ButtonWithLoading> </ButtonWithLoading>
</Form> </Form>
@@ -82,7 +82,7 @@ export const ResetPasswordForm = (props: ResetPasswordFormProps) => {
/> />
<div className="flex flex-col items-center gap-y-6 w-full"> <div className="flex flex-col items-center gap-y-6 w-full">
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}> <ButtonWithLoading className="mt-2 w-full h-11" isPending={mutation.isPending}>
Reset Reset
</ButtonWithLoading> </ButtonWithLoading>
<Link href="/login" className="group flex items-center text-sm hover:underline"> <Link href="/login" className="group flex items-center text-sm hover:underline">
@@ -55,7 +55,7 @@ export const RegisterForm = (props: registerFormProps) => {
<CardHeader> <CardHeader>
<div className="grid gap-2 text-center mb-2"> <div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Create an account</h1> <h1 className="text-3xl font-bold">Create an account</h1>
<p className="text-balance text-muted-foreground">Enter your informations bellow to register</p> <p className="text-balance text-muted-foreground">Enter your informations below to register</p>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -138,7 +138,7 @@ export const RegisterForm = (props: registerFormProps) => {
</FormItem> </FormItem>
)} )}
/> />
<Button type="submit" disabled={mutation.isPending}> <Button type="submit" className="h-11" disabled={mutation.isPending}>
Sign up Sign up
</Button> </Button>
<div className="mt-4 text-center text-sm"> <div className="mt-4 text-center text-sm">
@@ -47,7 +47,7 @@ export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
<CardHeader> <CardHeader>
<div className="grid gap-2 text-center mb-2"> <div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Reset Password</h1> <h1 className="text-3xl font-bold">Reset Password</h1>
<p className="text-balance text-muted-foreground">Fill information bellow to change your <p className="text-balance text-muted-foreground">Fill information below to change your
password</p> password</p>
</div> </div>
</CardHeader> </CardHeader>
@@ -34,7 +34,7 @@ export const GitHubStarsButtonCustom = () => {
<GithubButton <GithubButton
initialStars={0} initialStars={0}
targetStars={stars} targetStars={stars}
label="Github Stars" label=""
size="sm" size="sm"
separator={true} separator={true}
roundStars={true} roundStars={true}
@@ -15,6 +15,8 @@ export type paginationNavigationProps = {
export const PaginationNavigation = (props: paginationNavigationProps) => { export const PaginationNavigation = (props: paginationNavigationProps) => {
const { className, totalPages, currentPage, goToPage, goToPrevPage, goToNextPage, maxVisiblePages = 3 } = props; const { className, totalPages, currentPage, goToPage, goToPrevPage, goToNextPage, maxVisiblePages = 3 } = props;
if (totalPages <= 1) return null;
return ( return (
<Pagination className={cn("", className)}> <Pagination className={cn("", className)}>
<PaginationContent> <PaginationContent>
@@ -14,6 +14,10 @@ interface tablePaginationProps {
export function TablePagination(props: tablePaginationProps) { export function TablePagination(props: tablePaginationProps) {
const {className, table, maxVisiblePages = 3, pageSizeOptions = [10, 20, 30, 40, 50]} = props; const {className, table, maxVisiblePages = 3, pageSizeOptions = [10, 20, 30, 40, 50]} = props;
const totalPages = table.getPageCount();
if (totalPages <= 1) return null;
return ( return (
<div <div
className={cn("flex gap-x-4", className)} className={cn("flex gap-x-4", className)}
@@ -136,7 +136,7 @@ export const StorageGoogleDriveForm = ({form}: StorageGoogleDriveFormProps) => {
<input type="hidden" {...form.register("config.refreshToken")} /> <input type="hidden" {...form.register("config.refreshToken")} />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button type="button" onClick={handleConnect}> <Button type="button" variant={"secondary"} className="hover:cursor-pointer" onClick={handleConnect}>
{isConnected ? "Reconnect Google Drive" : "Connect Google Drive"} {isConnected ? "Reconnect Google Drive" : "Connect Google Drive"}
</Button> </Button>
@@ -1,11 +1,11 @@
"use client"; "use client";
import {Input} from "@/components/ui/input"; import {Input} from "@/components/ui/input";
import {Label} from "@/components/ui/label";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import {Copy, Check} from "lucide-react"; import {Copy, Check, Eye, EyeOff, Terminal, Key, Info} from "lucide-react";
import {useState} from "react"; import {useState} from "react";
import {copyToClipboardWithMeta} from "@/components/wrappers/common/button/copy-button"; import {copyToClipboardWithMeta} from "@/components/wrappers/common/button/copy-button";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
export type AgentCardKeyProps = { export type AgentCardKeyProps = {
edgeKey: string; edgeKey: string;
@@ -15,74 +15,131 @@ export type AgentCardKeyProps = {
export const AgentCardKey = ({edgeKey, agentName}: AgentCardKeyProps) => { export const AgentCardKey = ({edgeKey, agentName}: AgentCardKeyProps) => {
const [isCopiedKey, setIsCopiedKey] = useState(false); const [isCopiedKey, setIsCopiedKey] = useState(false);
const [isCopiedCommand, setIsCopiedCommand] = useState(false); const [isCopiedCommand, setIsCopiedCommand] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const command = `portabase agent "${agentName}" --key ${edgeKey}`; const command = `portabase agent "${agentName}" --key ${edgeKey}`;
const maskedKey = "••••••••••••••••••••••••••••••••";
const handleCopy = async (text: string, setter: (v: boolean) => void) => { const handleCopy = async (text: string, setter: (v: boolean) => void) => {
await copyToClipboardWithMeta(text); await copyToClipboardWithMeta(text);
setter(true); setter(true);
setTimeout(() => setter(false), 2000);
};
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
event.target.select();
}; };
return ( return (
<div className="grid gap-6 py-2"> <div className="flex flex-col gap-4 py-2">
<div className="space-y-4"> <Card className="border-muted/60 shadow-none py-0">
<div className="flex flex-col gap-2"> <CardHeader className="px-4 pt-4">
<Label className="text-sm font-semibold text-muted-foreground uppercase tracking-wider"> <CardTitle className="text-sm font-semibold flex items-center gap-2 uppercase tracking-tight">
1. Registration Key CLI Setup
</Label> </CardTitle>
<div className="flex items-center gap-2"> <CardDescription className="text-xs leading-relaxed">
<Input To setup your agent using the CLI, copy the command below and paste it in your terminal.
readOnly Make sure you have the PortaBase CLI installed. If not, you can <a href="https://portabase.io/docs/cli" target="_blank" className="underline hover:text-primary">install it here</a>.
value={edgeKey} </CardDescription>
onFocus={handleFocus} </CardHeader>
className="font-mono text-xs bg-muted/30 focus-visible:ring-1 cursor-pointer" <CardContent className="px-4 pb-4 space-y-4">
/> <div className="flex items-center gap-2">
<Button <div className="relative flex-1">
variant="outline" <Input
size="icon" readOnly
onClick={() => handleCopy(edgeKey, setIsCopiedKey)} value={isVisible ? command : `portabase agent "${agentName}" --key ${maskedKey}`}
className="shrink-0" onFocus={(e) => {
type="button" setIsVisible(true);
> handleCopy(command, setIsCopiedCommand);
{isCopiedKey ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />} e.currentTarget.select();
</Button> }}
</div> onClick={(e) => e.currentTarget.select()}
<p className="text-[11px] text-muted-foreground"> onBlur={() => {
Use this key for manual configuration of your agent. setIsVisible(false);
</p> setIsCopiedCommand(false);
</div> }}
className="font-mono text-xs bg-muted/30 h-10 pr-10 cursor-pointer"
/>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
setIsVisible(!isVisible);
}}
className="absolute right-1 top-1.5 h-7 w-7"
type="button"
>
{isVisible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
</div>
<Button
variant="outline"
onClick={() => handleCopy(command, setIsCopiedCommand)}
className="shrink-0 gap-2 h-10 px-4"
type="button"
>
{isCopiedCommand ? (
<><Check className="h-4 w-4 text-green-500" /></>
) : (
<><Copy className="h-4 w-4" /></>
)}
</Button>
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-2 pt-2"> <Card className="border-muted/60 shadow-none py-0">
<Label className="text-sm font-semibold text-muted-foreground uppercase tracking-wider"> <CardHeader className="px-4 pt-4">
2. Automatic Setup (CLI) <CardTitle className="text-sm font-semibold flex items-center gap-2 uppercase tracking-tight">
</Label> Manual Setup
<div className="flex items-center gap-2"> </CardTitle>
<Input <CardDescription className="text-xs leading-relaxed">
readOnly Use this key to manually configure your agent in your configuration file.
value={command} If you need help for manual configuration, you can follow our guide in the <a href="https://portabase.io/docs" target="_blank" className="underline hover:text-primary">documentation</a>.
onFocus={handleFocus} </CardDescription>
className="font-mono text-xs bg-muted/30 focus-visible:ring-1 cursor-pointer" </CardHeader>
/> <CardContent className="px-4 pb-4 space-y-4">
<Button <div className="flex items-center gap-2">
variant="outline" <div className="relative flex-1">
size="icon" <Input
onClick={() => handleCopy(command, setIsCopiedCommand)} readOnly
className="shrink-0" value={isVisible ? edgeKey : maskedKey}
type="button" onFocus={(e) => {
> setIsVisible(true);
{isCopiedCommand ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />} handleCopy(edgeKey, setIsCopiedKey);
</Button> e.currentTarget.select();
</div> }}
<p className="text-[11px] text-muted-foreground"> onClick={(e) => e.currentTarget.select()}
Run this command on your server to automatically register the agent. onBlur={() => {
</p> setIsVisible(false);
</div> setIsCopiedKey(false);
</div> }}
className="font-mono text-xs bg-muted/30 h-10 pr-10 cursor-pointer"
/>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
setIsVisible(!isVisible);
}}
className="absolute right-1 top-1.5 h-7 w-7 hover:bg-transparent"
type="button"
>
{isVisible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
</div>
<Button
variant="outline"
onClick={() => handleCopy(edgeKey, setIsCopiedKey)}
className="shrink-0 gap-2 h-10 px-4 "
type="button"
>
{isCopiedKey ? (
<><Check className="h-4 w-4 text-green-500" /></>
) : (
<><Copy className="h-4 w-4" /></>
)}
</Button>
</div>
</CardContent>
</Card>
</div> </div>
); );
}; };
@@ -6,10 +6,12 @@ import {Card} from "@/components/ui/card";
import {ConnectionIndicator} from "@/components/wrappers/common/connection-indicator"; import {ConnectionIndicator} from "@/components/wrappers/common/connection-indicator";
import {formatDateLastContact} from "@/utils/date-formatting"; import {formatDateLastContact} from "@/utils/date-formatting";
import {AgentWith} from "@/db/schema/08_agent"; import {AgentWith} from "@/db/schema/08_agent";
import {Activity, ChevronRight, Copy, Check, Fingerprint, Server, Database} from "lucide-react"; import {Activity, ChevronRight, Copy, Check, Fingerprint, Server, Database, AlertCircle} from "lucide-react";
import {Badge} from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import {truncateWords} from "@/utils/text"; import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile"; import {useIsMobile} from "@/hooks/use-mobile";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
import {useAgentUpdateCheck} from "@/features/agents/hooks/use-agent-update-check";
export type agentCardProps = { export type agentCardProps = {
data: AgentWith; data: AgentWith;
@@ -20,6 +22,8 @@ export const AgentCard = (props: agentCardProps) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const {newRelease, isUpdateAvailable} = useAgentUpdateCheck(agent.version);
const handleCopy = (e: React.MouseEvent) => { const handleCopy = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -44,9 +48,23 @@ export const AgentCard = (props: agentCardProps) => {
{isMobile ? truncateWords(agent.name, 2) : agent.name} {isMobile ? truncateWords(agent.name, 2) : agent.name}
</h3> </h3>
{agent.version && ( {agent.version && (
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-bold tracking-wider"> <div className="flex items-center gap-2">
v{agent.version} <Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-bold tracking-wider">
</Badge> v{agent.version}
</Badge>
{isUpdateAvailable && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center w-5 h-5 bg-yellow-500/10 rounded-full border border-yellow-500/20 text-yellow-600">
<AlertCircle className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>Update available: {newRelease?.tag_name}</p>
</TooltipContent>
</Tooltip>
)}
</div>
)} )}
</div> </div>
@@ -36,7 +36,7 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
}, },
staleTime: 0, staleTime: 0,
gcTime: 0, gcTime: 0,
refetchInterval: 5000, refetchInterval: 1000,
}); });
const agent = data?.data ?? initialAgent; const agent = data?.data ?? initialAgent;
@@ -69,7 +69,7 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
<div className="space-y-6"> <div className="space-y-6">
<Accordion type="single" collapsible defaultValue={!agent.lastContact ? "registration" : undefined}> <Accordion type="single" collapsible defaultValue={!agent.lastContact ? "registration" : undefined}>
<AccordionItem value="registration" className="border rounded-xl px-6 bg-card shadow-sm overflow-hidden transition-all duration-300 data-[state=open]:ring-1 data-[state=open]:ring-primary/20"> <AccordionItem value="registration" className="border rounded-xl px-6 bg-card shadow-sm overflow-hidden transition-all duration-300">
<AccordionTrigger className="hover:no-underline py-4 group"> <AccordionTrigger className="hover:no-underline py-4 group">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xl font-bold tracking-tight">Registration & Setup</span> <span className="text-xl font-bold tracking-tight">Registration & Setup</span>
@@ -90,7 +90,8 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
</Accordion> </Accordion>
</div> </div>
<div className="space-y-6"> {agent.databases.length > 0 && (
<div className="space-y-6">
<div className="flex items-center justify-between px-1"> <div className="flex items-center justify-between px-1">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Managed Databases</h2> <h2 className="text-2xl font-bold tracking-tight">Managed Databases</h2>
@@ -107,6 +108,7 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
cardItem={AgentDatabaseCard} cardItem={AgentDatabaseCard}
/> />
</div> </div>
)}
</div> </div>
) )
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import {useMutation, useQueryClient} from "@tanstack/react-query"; import {useMutation, useQueryClient} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {toast} from "sonner"; import {toast} from "sonner";
import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action"; import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
@@ -15,6 +16,7 @@ export type BackupButtonProps = {
export const BackupButton = (props: BackupButtonProps) => { export const BackupButton = (props: BackupButtonProps) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter();
const isMobile = useIsMobile() const isMobile = useIsMobile()
const mutation = useMutation({ const mutation = useMutation({
@@ -23,6 +25,7 @@ export const BackupButton = (props: BackupButtonProps) => {
if (backup?.data?.success) { if (backup?.data?.success) {
toast.success(backup.data.actionSuccess?.message || "Backup created successfully!"); toast.success(backup.data.actionSuccess?.message || "Backup created successfully!");
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]}); queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]});
router.refresh();
} else { } else {
toast.error(backup?.serverError || "Failed to create backup."); toast.error(backup?.serverError || "Failed to create backup.");
} }
@@ -1,26 +1,36 @@
"use client"; "use client";
import {PropsWithChildren, ReactNode, useState} from "react"; import {PropsWithChildren, ReactNode, useState} from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { CircleUser, LogOut } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { LogOut, User } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { signOut } from "@/lib/auth/auth-client"; import { signOut } from "@/lib/auth/auth-client";
import {ProfileModal} from "@/components/wrappers/dashboard/common/profile/profile-modal"; import {ProfileModal} from "@/components/wrappers/dashboard/common/profile/profile-modal";
import {Account, Session, User} from "@/db/schema/02_user";
import {Account, Session, User as UserType} from "@/db/schema/02_user";
import {AuthProviderConfig} from "../../../../../../portabase.config"; import {AuthProviderConfig} from "../../../../../../portabase.config";
export type LoggedInDropdownProps = PropsWithChildren<{ export type LoggedInDropdownProps = PropsWithChildren<{
user: User; user: UserType;
sessions: Session[]; sessions: Session[];
currentSession: Session; currentSession: Session;
accounts: Account[]; accounts: Account[];
children: ReactNode; children: ReactNode;
providers: AuthProviderConfig[] providers: AuthProviderConfig[]
}>; }>;
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers }: LoggedInDropdownProps) => { export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers }: LoggedInDropdownProps) => {
const router = useRouter(); const router = useRouter();
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
return ( return (
@@ -34,34 +44,50 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi
onOpenChange={setIsModalOpen} onOpenChange={setIsModalOpen}
providers={providers} providers={providers}
/> />
<DropdownMenu>
<DropdownMenu> <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> <DropdownMenuContent
<DropdownMenuContent side="top" className="min-w-[var(--radix-popper-anchor-width)]"> className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none p-1"
<DropdownMenuItem onClick={() => setIsModalOpen(!isModalOpen)}> align="start"
<div className="flex justify-start items-center gap-2"> side="top"
<CircleUser size={16} /> sideOffset={8}
<span>Account</span> >
</div> <DropdownMenuItem
</DropdownMenuItem> onClick={() => setIsModalOpen(!isModalOpen)}
<DropdownMenuItem className="group gap-2 p-1 cursor-pointer rounded-lg mb-1 transition-colors focus:bg-accent hover:bg-accent/50 border border-transparent"
onClick={async () => { >
await signOut({ <div className="flex size-9 items-center justify-center rounded-md border border-border bg-muted/50 shadow-sm transition-all group-hover:shadow-md group-hover:bg-background">
fetchOptions: { <User size={18} className="text-muted-foreground group-hover:text-foreground transition-colors" />
onSuccess: () => { </div>
router.push("/login"); <div className="flex flex-col">
}, <span className="text-sm font-medium leading-none">Account Settings</span>
}, </div>
}); </DropdownMenuItem>
}} <DropdownMenuItem
> className="group gap-2 p-1 cursor-pointer rounded-lg transition-colors focus:bg-red-50 dark:focus:bg-red-950/20 border border-transparent text-red-600 focus:text-red-600"
<div className="flex justify-start items-center gap-2"> onClick={async () => {
<LogOut size={16} className="text-red-500" /> await signOut({
<span className="text-red-500">Logout</span> fetchOptions: {
</div> onSuccess: () => {
</DropdownMenuItem> router.push("/login");
</DropdownMenuContent> },
</DropdownMenu> },
});
}}
>
<div className="flex size-9 items-center justify-center rounded-md border border-red-100 bg-red-50/50 dark:border-red-900/30 dark:bg-red-950/20 shadow-sm transition-all group-hover:shadow-md">
<LogOut size={18} className="text-red-500" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium leading-none">Logout</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</> </>
); );
}; };
@@ -7,20 +7,17 @@ import {
Layers, Layers,
ChartArea, ChartArea,
ShieldHalf, ShieldHalf,
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse, BookOpen
} from "lucide-react"; } from "lucide-react";
import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar"; import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar";
import {authClient, useSession} from "@/lib/auth/auth-client"; import {authClient} from "@/lib/auth/auth-client";
export const SidebarMenuCustomMain = () => { export const SidebarMenuCustomMain = () => {
const BASE_URL = `/dashboard`; const BASE_URL = `/dashboard`;
const {data: activeOrganization} = authClient.useActiveOrganization();
const {data: organizations} = authClient.useListOrganizations();
const {data: session, isPending, error} = authClient.useSession(); const {data: session, isPending, error} = authClient.useSession();
const member = authClient.useActiveMember();
if (isPending) return null; if (isPending) return null;
@@ -107,6 +104,23 @@ export const SidebarMenuCustomMain = () => {
}); });
} }
items.push(
{
label: "Resources",
type: "list",
group_content: [
{
title: "Documentation",
url: "https://portabase.io/docs",
icon: BookOpen,
type: "item",
redirect: true,
not_from_base_url: true,
}
],
},
)
return <SidebarMenuCustomBase baseUrl={BASE_URL} items={items}/>; return <SidebarMenuCustomBase baseUrl={BASE_URL} items={items}/>;
}; };
@@ -16,10 +16,9 @@ import {useMutation, useQueryClient} from "@tanstack/react-query";
import {BackupStorageWith} from "@/db/schema/14_storage-backup"; import {BackupStorageWith} from "@/db/schema/14_storage-backup";
import {TooltipProvider} from "@/components/ui/tooltip"; import {TooltipProvider} from "@/components/ui/tooltip";
import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common"; import {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import {truncateWords} from "@/utils/text";
import {useIsMobile} from "@/hooks/use-mobile";
import {Badge} from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns"; import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
import {useRouter} from "next/navigation";
import { import {
createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction, createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction,
downloadBackupAction downloadBackupAction
@@ -39,9 +38,9 @@ type BackupActionsFormProps = {
export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => { export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
const filteredBackupStorages = backup.storages?.filter((storage) => storage.deletedAt === null) ?? [] const filteredBackupStorages = backup.storages?.filter((storage) => storage.deletedAt === null) ?? []
const isMobile = useIsMobile();
const {closeModal} = useBackupModal(); const {closeModal} = useBackupModal();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter();
const form = useZodForm({ const form = useZodForm({
schema: BackupActionsSchema, schema: BackupActionsSchema,
@@ -75,6 +74,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
if (inner?.success) { if (inner?.success) {
toast.success(inner.actionSuccess?.message); toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]}); queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
router.refresh();
if (action === "download") { if (action === "download") {
const url = inner.value const url = inner.value
if (typeof url === "string") { if (typeof url === "string") {
@@ -92,6 +92,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
if (action === "delete") { if (action === "delete") {
toast.success("Backup deleted successfully.") toast.success("Backup deleted successfully.")
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]}); queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
router.refresh();
closeModal() closeModal()
} else { } else {
toast.error(inner?.actionError?.message ?? "An error occurred."); toast.error(inner?.actionError?.message ?? "An error occurred.");
@@ -113,6 +114,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
if (inner?.success) { if (inner?.success) {
toast.success(inner.actionSuccess?.message); toast.success(inner.actionSuccess?.message);
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]}); queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
router.refresh();
closeModal() closeModal()
} else { } else {
toast.error(inner?.actionError?.message); toast.error(inner?.actionError?.message);
@@ -175,24 +177,26 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1 min-w-0 flex flex-col gap-1"> <div className="flex-1 min-w-0 flex flex-col gap-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-w-0">
{getChannelIcon(storage.storageChannel?.provider || "")} <div className="shrink-0">
<h3 className="font-medium text-foreground"> {getChannelIcon(storage.storageChannel?.provider || "")}
{isMobile ? truncateWords(storage?.storageChannel?.name ?? "", 2) : storage.storageChannel?.name} </div>
</h3> <h3 className="font-medium text-foreground truncate">
<Badge variant="secondary" {storage.storageChannel?.name}
className="text-xs font-mono"> </h3>
{storage.storageChannel?.provider} <Badge variant="secondary"
className="text-xs font-mono shrink-0">
{storage.storageChannel?.provider}
</Badge>
</div>
<Badge variant="outline"
className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}>
{getStatusIcon(storage.status === "success")}
<span
className="capitalize">{storage.status.toUpperCase()}</span>
</Badge> </Badge>
</div> </div>
<Badge variant="outline"
className={`gap-1.5 ${getStatusColor(storage.status)}`}>
{getStatusIcon(storage.status === "success")}
<span
className="capitalize">{storage.status.toUpperCase()}</span>
</Badge>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -5,6 +5,8 @@ import {db} from "@/db";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {BackupWith, Restoration} from "@/db/schema/07_database"; import {BackupWith, Restoration} from "@/db/schema/07_database";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
export const getDatabaseDataAction = userAction export const getDatabaseDataAction = userAction
.schema( .schema(
@@ -19,6 +21,9 @@ export const getDatabaseDataAction = userAction
where: eq(drizzleDb.schemas.database.id, databaseId), where: eq(drizzleDb.schemas.database.id, databaseId),
with: { with: {
project: true, project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true
} }
}); });
@@ -45,10 +50,23 @@ export const getDatabaseDataAction = userAction
const successfulBackups = backups.filter(b => b.status === "success").length; const successfulBackups = backups.filter(b => b.status === "success").length;
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
let activeOrganizationChannels = [];
let activeOrganizationStorageChannels = [];
if (database?.project?.organizationId) {
const organizationChannels = await getOrganizationChannels(database.project.organizationId);
activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled);
const organizationStorageChannels = await getOrganizationStorageChannels(database.project.organizationId);
activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
}
return { return {
database, database,
backups, backups,
restorations, restorations,
activeOrganizationChannels,
activeOrganizationStorageChannels,
stats: { stats: {
totalBackups, totalBackups,
availableBackups, availableBackups,
@@ -14,6 +14,7 @@ import {Switch} from "@/components/ui/switch";
import {Card} from "@/components/ui/card"; import {Card} from "@/components/ui/card";
import Link from "next/link"; import Link from "next/link";
import {useIsMobile} from "@/hooks/use-mobile"; import {useIsMobile} from "@/hooks/use-mobile";
import {useRouter} from "next/navigation";
import { import {
ChannelKind, ChannelKind,
getChannelIcon, getChannelIcon,
@@ -48,6 +49,7 @@ export const ChannelPoliciesForm = ({
kind kind
}: ChannelPoliciesFormProps) => { }: ChannelPoliciesFormProps) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const channelText = getChannelTextBasedOnKind(kind); const channelText = getChannelTextBasedOnKind(kind);
@@ -130,6 +132,7 @@ export const ChannelPoliciesForm = ({
onSuccess: () => { onSuccess: () => {
toast.success("Policies saved successfully"); toast.success("Policies saved successfully");
queryClient.invalidateQueries({queryKey: ["database-data", database.id]}); queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
router.refresh();
}, },
onError: (error: any) => { toast.error(error.message || "Failed to save policies"); }, onError: (error: any) => { toast.error(error.message || "Failed to save policies"); },
}); });
@@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/cron-button/cron.action"; import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/cron-button/cron.action";
import {Database} from "@/db/schema/07_database"; import {Database} from "@/db/schema/07_database";
@@ -18,6 +19,7 @@ export type CronButtonProps = {
export const CronButton = (props: CronButtonProps) => { export const CronButton = (props: CronButtonProps) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter();
const [isSwitched, setIsSwitched] = useState(props.database.backupPolicy !== null); const [isSwitched, setIsSwitched] = useState(props.database.backupPolicy !== null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -27,6 +29,7 @@ export const CronButton = (props: CronButtonProps) => {
onSuccess: () => { onSuccess: () => {
toast.success(`Method updated successfully.`); toast.success(`Method updated successfully.`);
queryClient.invalidateQueries({ queryKey: ["database-data", props.database.id] }); queryClient.invalidateQueries({ queryKey: ["database-data", props.database.id] });
router.refresh();
}, },
onError: () => { onError: () => {
toast.error(`An error occurred while updating backup method.`); toast.error(`An error occurred while updating backup method.`);
@@ -1,6 +1,7 @@
import {AdvancedCronSelect} from "./advanced-cron-select"; import {AdvancedCronSelect} from "./advanced-cron-select";
import {updateDatabaseBackupPolicyAction} from "@/components/wrappers/dashboard/database/cron-button/cron.action"; import {updateDatabaseBackupPolicyAction} from "@/components/wrappers/dashboard/database/cron-button/cron.action";
import {useMutation, useQueryClient} from "@tanstack/react-query"; import {useMutation, useQueryClient} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {useState} from "react"; import {useState} from "react";
import {toast} from "sonner"; import {toast} from "sonner";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
@@ -15,6 +16,7 @@ export type CronInputProps = {
export const CronInput = ({database, onSuccess}: CronInputProps) => { export const CronInput = ({database, onSuccess}: CronInputProps) => {
const [cron, setCron] = useState<string>(database.backupPolicy ?? "* * * * *"); const [cron, setCron] = useState<string>(database.backupPolicy ?? "* * * * *");
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter();
const updateBackupPolicy = useMutation({ const updateBackupPolicy = useMutation({
mutationFn: (value: string) => updateDatabaseBackupPolicyAction({databaseId: database.id, backupPolicy: value}), mutationFn: (value: string) => updateDatabaseBackupPolicyAction({databaseId: database.id, backupPolicy: value}),
@@ -22,6 +24,7 @@ export const CronInput = ({database, onSuccess}: CronInputProps) => {
toast.success(`Cron updated successfully.`); toast.success(`Cron updated successfully.`);
onSuccess?.() onSuccess?.()
queryClient.invalidateQueries({queryKey: ["database-data", database.id]}); queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
router.refresh();
}, },
onError: () => { onError: () => {
toast.error(`An error occurred while updating cron value.`); toast.error(`An error occurred while updating cron value.`);
@@ -2,6 +2,7 @@
import {DropZoneFile} from "@/components/wrappers/common/dropzone/dropzone-file"; import {DropZoneFile} from "@/components/wrappers/common/dropzone/dropzone-file";
import {useMutation, useQueryClient} from "@tanstack/react-query"; import {useMutation, useQueryClient} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {useState} from "react"; import {useState} from "react";
import {Loader2} from "lucide-react"; import {Loader2} from "lucide-react";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
@@ -16,6 +17,7 @@ type UploadRetentionZoneProps = {
export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZoneProps) => { export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZoneProps) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter();
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
@@ -37,6 +39,7 @@ export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZ
toast.success(inner.actionSuccess?.message); toast.success(inner.actionSuccess?.message);
onSuccessAction?.() onSuccessAction?.()
queryClient.invalidateQueries({queryKey: ["database-data", databaseId]}); queryClient.invalidateQueries({queryKey: ["database-data", databaseId]});
router.refresh();
} else { } else {
toast.error(inner?.actionError?.message); toast.error(inner?.actionError?.message);
} }
@@ -65,7 +68,7 @@ export const UploadBackupZone = ({onSuccessAction, databaseId}: UploadRetentionZ
) : ( ) : (
<DropZoneFile <DropZoneFile
accept={acceptDbImportFiles} accept={acceptDbImportFiles}
maxSize={500 * 1024 * 1024} maxSize={2 * 1024 * 1024 * 1024}
maxFiles={1} maxFiles={1}
description="Import database backup" description="Import database backup"
fileKind="Database file (.sql, .dump)" fileKind="Database file (.sql, .dump)"
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import {RetentionSettings, RetentionSettingsSchema} from "./backup-retention-settings.schema"; import {RetentionSettings, RetentionSettingsSchema} from "./backup-retention-settings.schema";
import {useMutation, useQueryClient} from "@tanstack/react-query"; import {useMutation, useQueryClient} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {updateOrCreateBackupRetentionPolicyAction} from "./backup-retention-settings.action"; import {updateOrCreateBackupRetentionPolicyAction} from "./backup-retention-settings.action";
import {DatabaseWith, RetentionPolicy} from "@/db/schema/07_database"; import {DatabaseWith, RetentionPolicy} from "@/db/schema/07_database";
import {toast} from "sonner"; import {toast} from "sonner";
@@ -28,6 +29,7 @@ export type BackupRetentionSettingsFormProps = {
export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRetentionSettingsFormProps) => { export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRetentionSettingsFormProps) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter();
const defaultValuesFormatted: RetentionSettings = { const defaultValuesFormatted: RetentionSettings = {
type: defaultValues?.type, type: defaultValues?.type,
@@ -55,6 +57,7 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
onSuccess: () => { onSuccess: () => {
toast.success("Retention policy updated successfully."); toast.success("Retention policy updated successfully.");
queryClient.invalidateQueries({queryKey: ["database-data", database.id]}); queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
router.refresh();
}, },
onError: () => { onError: () => {
toast.error("An error occurred while updating retention policy."); toast.error("An error occurred while updating retention policy.");
@@ -18,7 +18,7 @@ import {Skeleton} from "@/components/ui/skeleton"
export function OrganizationCombobox() { export function OrganizationCombobox() {
const router = useRouter() const router = useRouter()
const {isMobile, state} = useSidebar() const {state} = useSidebar()
const {data: organizations, isPending: isPendingList, refetch} = authClient.useListOrganizations() const {data: organizations, isPending: isPendingList, refetch} = authClient.useListOrganizations()
const { const {
data: activeOrganization, data: activeOrganization,
@@ -95,7 +95,7 @@ export function OrganizationCombobox() {
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className="min-w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none" className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none"
align="start" align="start"
side="bottom" side="bottom"
sideOffset={4} sideOffset={4}
@@ -113,7 +113,7 @@ export function OrganizationCombobox() {
: "focus:bg-accent hover:bg-accent/50 border border-transparent" : "focus:bg-accent hover:bg-accent/50 border border-transparent"
)}> )}>
<div className={cn( <div className={cn(
"flex size-9 items-center justify-center rounded-md border shadow-sm transition-all group-hover:shadow-md", "flex size-9 shrink-0 items-center justify-center rounded-md border shadow-sm transition-all group-hover:shadow-md",
org.logo ? "bg-transparent border-transparent" : "", org.logo ? "bg-transparent border-transparent" : "",
isActive && !org.logo ? "bg-primary text-primary-foreground border-primary/30" : "bg-muted/50 border-border" isActive && !org.logo ? "bg-primary text-primary-foreground border-primary/30" : "bg-muted/50 border-border"
)}> )}>
@@ -126,7 +126,7 @@ export function OrganizationCombobox() {
)}/> )}/>
)} )}
</div> </div>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5 flex-1 min-w-0">
<span className={cn( <span className={cn(
"text-sm max-w-42.5 truncate font-medium leading-none", "text-sm max-w-42.5 truncate font-medium leading-none",
isActive ? "text-primary" : "" isActive ? "text-primary" : ""
@@ -150,7 +150,7 @@ export function OrganizationCombobox() {
className="flex size-9 items-center justify-center rounded-md border border-dashed border-muted-foreground/30 bg-background transition-colors group-hover:border-primary/50 group-hover:bg-primary/5"> className="flex size-9 items-center justify-center rounded-md border border-dashed border-muted-foreground/30 bg-background transition-colors group-hover:border-primary/50 group-hover:bg-primary/5">
<Plus className="size-4"/> <Plus className="size-4"/>
</div> </div>
<div className="font-medium">Create new organization</div> <div className="font-medium">Create organization</div>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -145,7 +145,7 @@ export default function TwoFactorForm({ onSuccess, onSuccessData }: TwoFactorFor
/> />
<div className="flex flex-col gap-3 pt-2"> <div className="flex flex-col gap-3 pt-2">
<Button type="submit" disabled={isPending}> <Button type="submit" className="h-11" disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Verify Verify
</Button> </Button>
@@ -21,7 +21,7 @@ export function ProfileAppearance() {
} }
function ThemeSelector() { function ThemeSelector() {
const {theme, setTheme} = useTheme(); const {theme} = useTheme();
return ( return (
@@ -39,7 +39,6 @@ function ThemeSelector() {
isActive ? "border-primary bg-primary/5" : "border-muted/40" isActive ? "border-primary bg-primary/5" : "border-muted/40"
)} )}
onClick={async () => { onClick={async () => {
// setTheme(item.value)
await authClient.updateUser({theme: item.value}); await authClient.updateUser({theme: item.value});
}} }}
> >
@@ -37,6 +37,7 @@ export type DatabaseContentProps = {
export const DatabaseContent = (props: DatabaseContentProps) => { export const DatabaseContent = (props: DatabaseContentProps) => {
const {} = useBackupModal(); const {} = useBackupModal();
const {data} = useQuery({ const {data} = useQuery({
queryKey: ["database-data", props.database.id], queryKey: ["database-data", props.database.id],
queryFn: async () => { queryFn: async () => {
@@ -44,12 +45,16 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
return result?.data; return result?.data;
}, },
initialData: { initialData: {
// TODO : to be patched
// @ts-ignore
database: { database: {
...props.database, ...props.database,
project: props.database.project ?? null, project: props.database.project ?? null,
}, },
backups: props.backups, backups: props.backups,
restorations: props.restorations, restorations: props.restorations,
activeOrganizationChannels: props.activeOrganizationChannels,
activeOrganizationStorageChannels: props.activeOrganizationStorageChannels,
stats: { stats: {
totalBackups: props.totalBackups, totalBackups: props.totalBackups,
availableBackups: props.availableBackups, availableBackups: props.availableBackups,
@@ -58,12 +63,14 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
}, },
staleTime: 0, staleTime: 0,
gcTime: 0, gcTime: 0,
refetchInterval: 5000, refetchInterval: 1000,
}); });
const database = data?.database ?? props.database; const database = data?.database ?? props.database;
const backups = data?.backups ?? props.backups; const backups = data?.backups ?? props.backups;
const restorations = data?.restorations ?? props.restorations; const restorations = data?.restorations ?? props.restorations;
const activeOrganizationChannels = data?.activeOrganizationChannels ?? props.activeOrganizationChannels;
const activeOrganizationStorageChannels = data?.activeOrganizationStorageChannels ?? props.activeOrganizationStorageChannels;
const stats = data?.stats ?? { const stats = data?.stats ?? {
totalBackups: props.totalBackups, totalBackups: props.totalBackups,
availableBackups: props.availableBackups, availableBackups: props.availableBackups,
@@ -91,14 +98,14 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
database={database} database={database}
kind={"notification"} kind={"notification"}
icon={<Megaphone/>} icon={<Megaphone/>}
channels={props.activeOrganizationChannels} channels={activeOrganizationChannels}
organizationId={props.organizationId} organizationId={props.organizationId}
/> />
<ChannelPoliciesModal <ChannelPoliciesModal
database={database} database={database}
icon={<HardDrive/>} icon={<HardDrive/>}
kind={"storage"} kind={"storage"}
channels={props.activeOrganizationStorageChannels} channels={activeOrganizationStorageChannels}
organizationId={props.organizationId} organizationId={props.organizationId}
/> />
<ImportModal database={database}/> <ImportModal database={database}/>
@@ -0,0 +1,19 @@
"use client";
import {useQuery} from "@tanstack/react-query";
import {getNewAgentRelease} from "@/features/updates/services/github";
export const useAgentUpdateCheck = (currentVersion?: string | null) => {
const {data: newRelease, isLoading} = useQuery({
queryKey: ["agent-new-release", currentVersion],
queryFn: () => currentVersion ? getNewAgentRelease(currentVersion) : Promise.resolve(null),
staleTime: 1000 * 60 * 60,
enabled: !!currentVersion,
});
return {
newRelease,
isLoading,
isUpdateAvailable: !!newRelease,
};
};
+61 -25
View File
@@ -1,41 +1,77 @@
"use client" "use client"
import { Moon, Sun, Check, SunMoon } from "lucide-react"
import { useTheme } from "next-themes"
import { authClient } from "@/lib/auth/auth-client"
import * as React from "react" import { Button } from "@/components/ui/button"
import {Moon, Sun} from "lucide-react" import {
import {Button} from "@/components/ui/button" DropdownMenu,
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu" DropdownMenuContent,
import {authClient} from "@/lib/auth/auth-client"; DropdownMenuItem,
import {toast} from "sonner"; DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
const themes = [
{ id: "light", icon: Sun, label: "Light" },
{ id: "dark", icon: Moon, label: "Dark" },
{ id: "system", icon: SunMoon, label: "System" },
] as const
export function ModeToggle() { export function ModeToggle() {
const { theme } = useTheme()
const setTheme = async (theme: "light" | "dark" | "system") => { const handleThemeChange = async (newTheme: "light" | "system" | "dark") => {
toast.success("Your theme preference has been updated."); await authClient.updateUser({ theme: newTheme })
await authClient.updateUser({theme: theme}); }
};
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm"> <Button variant="ghost" size="icon" className="size-8 rounded-full border border-input bg-transparent shadow-xs transition-transform active:scale-95">
<Sun className="size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"/> {theme === "light" ? <Sun className="size-4" /> : theme === "dark" ? <Moon className="size-4" /> : <SunMoon className="size-4" />}
<Moon className="absolute size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"/>
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end" className="min-w-37.5 rounded-xl border-2 border-border p-1 shadow-none">
<DropdownMenuItem onClick={() => setTheme("light")}> {themes.map((t) => {
Light const ThemeIcon = t.icon
</DropdownMenuItem> const isActive = theme === t.id
<DropdownMenuItem onClick={() => setTheme("dark")}> return (
Dark <DropdownMenuItem
</DropdownMenuItem> key={t.id}
<DropdownMenuItem onClick={() => setTheme("system")}> onClick={() => handleThemeChange(t.id)}
System className={cn(
</DropdownMenuItem> "group gap-2 p-2 cursor-pointer rounded-lg mb-1 last:mb-0 transition-colors",
isActive
? "bg-primary/10 text-primary border border-primary/20"
: "focus:bg-accent hover:bg-accent/50 border border-transparent"
)}
>
<div className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md border shadow-sm transition-all group-hover:shadow-md",
isActive ? "bg-primary text-primary-foreground border-primary/30" : "bg-muted/50 border-border"
)}>
<ThemeIcon className={cn(
"size-4",
isActive ? "text-primary-foreground" : "text-muted-foreground"
)} />
</div>
<span className={cn(
"text-sm font-medium leading-none flex-1",
isActive ? "text-primary" : ""
)}>
{t.label}
</span>
{isActive && (
<div className="flex size-4 items-center justify-center rounded-full bg-primary shadow-sm ml-auto">
<Check className="size-2.5 text-primary-foreground" strokeWidth={3} />
</div>
)}
</DropdownMenuItem>
)
})}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) )
} }
@@ -18,9 +18,9 @@ export const UpdateNotification = () => {
<SidebarGroup className="py-0"> <SidebarGroup className="py-0">
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem className="px-2"> <SidebarMenuItem>
<div <div
className="relative flex flex-col gap-2 rounded-lg border bg-primary/5 p-3 text-sidebar-foreground border-primary/20"> className="w-[var(--radix-popper-anchor-width)] relative flex flex-col gap-2 rounded-lg border bg-primary/5 p-3 text-sidebar-foreground border-primary/20">
<button <button
onClick={dismissUpdate} onClick={dismissUpdate}
className="absolute right-2 top-2 rounded-md p-0.5 text-muted-foreground/50 hover:bg-sidebar-accent hover:text-foreground transition-colors" className="absolute right-2 top-2 rounded-md p-0.5 text-muted-foreground/50 hover:bg-sidebar-accent hover:text-foreground transition-colors"
@@ -37,10 +37,12 @@ export const UpdateNotification = () => {
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-[10px] font-semibold leading-none">Update available</span> <span className="text-[10px] font-semibold leading-none">Update available</span>
<span {newRelease.tag_name && (
className="text-[10px] text-muted-foreground font-medium px-1 py-0.5 bg-primary/10 rounded-full"> <span
v{newRelease.tag_name.replace(/^v/, "")} className="text-[10px] text-muted-foreground font-medium px-1 py-0.5 bg-primary/10 rounded-full">
</span> v{newRelease.tag_name.replace(/^v/, "")}
</span>
)}
</div> </div>
<Link <Link
href={newRelease.html_url} href={newRelease.html_url}
+63 -41
View File
@@ -14,7 +14,7 @@ type ParsedVersion = {
}; };
function parseVersion(version: string): ParsedVersion { function parseVersion(version: string): ParsedVersion {
const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/); const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/);
if (!match) return { if (!match) return {
major: 0, major: 0,
minor: 0, minor: 0,
@@ -30,6 +30,49 @@ function parseVersion(version: string): ParsedVersion {
}; };
} }
const findLatestVersion = (currentVersion: string, releases: GitHubRelease[]): GitHubRelease | null => {
const cleanCurrentVersion = currentVersion.replace(/^v/, "");
const isStable = /^\d+\.\d+\.\d+$/.test(cleanCurrentVersion);
const isRc = /^\d+\.\d+\.\d+-rc\.\d+$/.test(cleanCurrentVersion);
const currentParsedVersion = parseVersion(cleanCurrentVersion);
if (isRc) {
const latestStableVersion = releases.find(r => !r.prerelease);
const latestRcVersion = releases.find(r => r.prerelease && (r.tag_name.includes("rc") || (r.name && r.name.includes("rc"))));
if (latestStableVersion) {
const versionStr = latestStableVersion.tag_name || latestStableVersion.name;
const latestStableParsedVersion = parseVersion(versionStr);
if (latestStableParsedVersion.major > currentParsedVersion.major) return latestStableVersion;
if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor > currentParsedVersion.minor) return latestStableVersion;
if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor === currentParsedVersion.minor && latestStableParsedVersion.patch >= currentParsedVersion.patch) return latestStableVersion;
}
if (latestRcVersion) {
const versionStr = latestRcVersion.tag_name || latestRcVersion.name;
const latestRcParsedVersion = parseVersion(versionStr);
if (latestRcParsedVersion.major > currentParsedVersion.major) return latestRcVersion;
if (latestRcParsedVersion.major === currentParsedVersion.major && latestRcParsedVersion.minor > currentParsedVersion.minor) return latestRcVersion;
if (latestRcParsedVersion.major === currentParsedVersion.major && latestRcParsedVersion.minor === currentParsedVersion.minor && latestRcParsedVersion.patch > currentParsedVersion.patch) return latestRcVersion;
if (latestRcParsedVersion.major === currentParsedVersion.major && latestRcParsedVersion.minor === currentParsedVersion.minor && latestRcParsedVersion.patch === currentParsedVersion.patch &&
latestRcParsedVersion.rc !== undefined && currentParsedVersion.rc !== undefined && latestRcParsedVersion.rc > currentParsedVersion.rc) return latestRcVersion;
}
} else if (isStable) {
const latestStableVersion = releases.find(r => !r.prerelease);
if (latestStableVersion) {
const versionStr = latestStableVersion.tag_name || latestStableVersion.name;
const latestStableParsedVersion = parseVersion(versionStr);
if (latestStableParsedVersion.major > currentParsedVersion.major) return latestStableVersion;
if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor > currentParsedVersion.minor) return latestStableVersion;
if (latestStableParsedVersion.major === currentParsedVersion.major && latestStableParsedVersion.minor === currentParsedVersion.minor && latestStableParsedVersion.patch > currentParsedVersion.patch) return latestStableVersion;
}
}
return null;
};
export const getNewRelease = async (currentVersion: string): Promise<GitHubRelease | null> => { export const getNewRelease = async (currentVersion: string): Promise<GitHubRelease | null> => {
try { try {
const response = await fetch("https://api.github.com/repos/Portabase/portabase/releases"); const response = await fetch("https://api.github.com/repos/Portabase/portabase/releases");
@@ -38,49 +81,28 @@ export const getNewRelease = async (currentVersion: string): Promise<GitHubRelea
} }
const releases: GitHubRelease[] = await response.json(); const releases: GitHubRelease[] = await response.json();
return findLatestVersion(currentVersion, releases);
const isStable = /^\d+\.\d+\.\d+$/.test(currentVersion);
const isRc = /^\d+\.\d+\.\d+-rc\.\d+$/.test(currentVersion);
console.log("isStable", isStable)
console.log("isRc", isRc)
const currentParsedVersion = parseVersion(currentVersion)
const latestStableVersion = releases.find(r => !r.prerelease)
const latestRcVersion = releases.find(r => r.name.includes("rc"))!
if (isRc) {
if (!latestRcVersion) return null
const latestRcParsedVersion = parseVersion(latestRcVersion.name)
if (currentParsedVersion.major < latestRcParsedVersion.major) return latestRcVersion;
if (currentParsedVersion.minor < latestRcParsedVersion.minor) return latestRcVersion;
if (currentParsedVersion.patch < latestRcParsedVersion.patch) return latestRcVersion;
if (currentParsedVersion.rc! < latestRcParsedVersion.rc!) return latestRcVersion;
if (!latestStableVersion) return null
const latestStableParsedVersion = parseVersion(latestStableVersion.name)
if (currentParsedVersion.major < latestStableParsedVersion.major) return latestStableVersion;
if (currentParsedVersion.minor < latestStableParsedVersion.minor) return latestStableVersion;
if (currentParsedVersion.patch < latestStableParsedVersion.patch) return latestStableVersion;
return null;
} else if (isStable) {
if (!latestStableVersion) return null
const latestStableParsedVersion = parseVersion(latestStableVersion.name)
if (currentParsedVersion.major < latestStableParsedVersion.major) return latestStableVersion;
if (currentParsedVersion.minor < latestStableParsedVersion.minor) return latestStableVersion;
if (currentParsedVersion.patch < latestStableParsedVersion.patch) return latestStableVersion;
return null;
}
return null;
} catch (error) { } catch (error) {
console.error("Failed to fetch latest release", error); console.error("Failed to fetch latest release", error);
return null; return null;
} }
}; };
export const getNewAgentRelease = async (currentVersion: string): Promise<GitHubRelease | null> => {
try {
const response = await fetch("https://api.github.com/repos/Portabase/agent-rust/releases", {
next: { revalidate: 3600 }
});
if (!response.ok) {
return null;
}
const releases: GitHubRelease[] = await response.json();
return findLatestVersion(currentVersion, releases);
} catch (error) {
console.error("Failed to fetch latest agent release", error);
return null;
}
};