feat(dashboard): setting up layout
This commit is contained in:
+2
-1
@@ -1,6 +1,7 @@
|
||||
NODE_ENV=development
|
||||
BASANGO_API_HOST=localhost
|
||||
BASANGO_API_PORT=3000
|
||||
BASANGO_API_PORT=3080
|
||||
BASANGO_API_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
BASANGO_API_KEY=your_api_key_here
|
||||
BASANGO_CRAWLER_KEY=dev
|
||||
BASANGO_JWT_SECRET=your_jwt_secret_here
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"@basango/encryption": "workspace:*",
|
||||
"@basango/logger": "workspace:*",
|
||||
"@hono/node-server": "^1.19.6",
|
||||
"@hono/trpc-server": "^0.4.0",
|
||||
"@hono/zod-openapi": "^1.1.4",
|
||||
"@scalar/hono-api-reference": "^0.9.24",
|
||||
"@trpc/server": "^11.7.1",
|
||||
@@ -16,6 +17,9 @@
|
||||
"zod": "catalog:",
|
||||
"zod-openapi": "^5.4.3"
|
||||
},
|
||||
"exports": {
|
||||
"./trpc/routers/_app": "./src/trpc/routers/_app.ts"
|
||||
},
|
||||
"name": "@basango/api",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -28,6 +28,7 @@ export const { env, config } = defineConfig({
|
||||
"BASANGO_API_ALLOWED_ORIGINS",
|
||||
"BASANGO_API_KEY",
|
||||
"BASANGO_CRAWLER_KEY",
|
||||
"BASANGO_JWT_SECRET",
|
||||
],
|
||||
path: path.join(PROJECT_DIR, ".env"),
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { trpcServer } from "@hono/trpc-server";
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { Scalar } from "@scalar/hono-api-reference";
|
||||
import { cors } from "hono/cors";
|
||||
@@ -5,6 +6,8 @@ import { secureHeaders } from "hono/secure-headers";
|
||||
|
||||
import { config, env } from "@/config";
|
||||
import { routers } from "@/rest/routers";
|
||||
import { createTRPCContext } from "@/trpc/init";
|
||||
import { appRouter } from "@/trpc/routers/_app";
|
||||
|
||||
const app = new OpenAPIHono();
|
||||
|
||||
@@ -21,6 +24,14 @@ app.use(
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(
|
||||
"/trpc/*",
|
||||
trpcServer({
|
||||
createContext: createTRPCContext,
|
||||
router: appRouter,
|
||||
}),
|
||||
);
|
||||
|
||||
app.doc("/openapi", {
|
||||
info: {
|
||||
contact: {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const idSchema = z.uuid().openapi({
|
||||
description: "The unique identifier of the source.",
|
||||
example: "b3e1c8f4-5d6a-4c9e-8f1e-2d3c4b5a6f7g",
|
||||
});
|
||||
|
||||
const biasSchema = z.enum(["neutral", "slightly", "partisan", "extreme"]).openapi({
|
||||
description: "The bias level of the source.",
|
||||
example: "neutral",
|
||||
});
|
||||
const reliabilitySchema = z
|
||||
.enum(["trusted", "reliable", "average", "low_trust", "unreliable"])
|
||||
.openapi({
|
||||
description: "The reliability level of the source.",
|
||||
example: "trusted",
|
||||
});
|
||||
|
||||
const transparencySchema = z.enum(["high", "medium", "low"]).openapi({
|
||||
description: "The transparency level of the source.",
|
||||
example: "high",
|
||||
});
|
||||
|
||||
const credibilitySchema = z
|
||||
.object({
|
||||
bias: biasSchema.default("neutral"),
|
||||
reliability: reliabilitySchema.default("average"),
|
||||
transparency: transparencySchema.default("medium"),
|
||||
})
|
||||
.openapi({
|
||||
description: "Credibility information about the source.",
|
||||
});
|
||||
|
||||
export const createSourceSchema = z.object({
|
||||
credibility: credibilitySchema.optional(),
|
||||
description: z.string().max(1024).optional().openapi({
|
||||
description: "A brief description of the source.",
|
||||
example: "Radio Okapi is a Congolese radio station that provides news and information.",
|
||||
}),
|
||||
displayName: z.string().min(1).max(255).optional().openapi({
|
||||
description: "The display name of the source.",
|
||||
example: "Radio Okapi",
|
||||
}),
|
||||
name: z.string().min(1).max(255).openapi({
|
||||
description: "The name of the source.",
|
||||
example: "radiookapi.com",
|
||||
}),
|
||||
url: z.url().openapi({
|
||||
description: "The URL of the source.",
|
||||
example: "https://techcrunch.com",
|
||||
}),
|
||||
});
|
||||
|
||||
export const getSourceSchema = z.object({
|
||||
id: idSchema,
|
||||
});
|
||||
|
||||
export const updateSourceSchema = z.object({
|
||||
credibility: credibilitySchema.optional(),
|
||||
description: createSourceSchema.shape.description,
|
||||
displayName: createSourceSchema.shape.displayName,
|
||||
id: idSchema,
|
||||
name: createSourceSchema.shape.name.optional(),
|
||||
});
|
||||
|
||||
export const createSourceResponseSchema = z.object({
|
||||
id: idSchema,
|
||||
...createSourceSchema.shape,
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Database, db } from "@basango/db/client";
|
||||
import { initTRPC } from "@trpc/server";
|
||||
import type { Context } from "hono";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { withAuthentication } from "@/trpc/middlewares/auth";
|
||||
import { withDatabase } from "@/trpc/middlewares/db";
|
||||
import { Session, verifyAccessToken } from "@/utils/auth";
|
||||
import { getGeoContext } from "@/utils/geo";
|
||||
|
||||
type TRPCContext = {
|
||||
session: Session | null;
|
||||
db: Database;
|
||||
geo: ReturnType<typeof getGeoContext>;
|
||||
};
|
||||
|
||||
export const createTRPCContext = async (_: unknown, c: Context): Promise<TRPCContext> => {
|
||||
const accessToken = c.req.header("Authorization")?.split(" ")[1];
|
||||
const session = await verifyAccessToken(accessToken);
|
||||
const geo = getGeoContext(c.req);
|
||||
|
||||
return {
|
||||
db,
|
||||
geo,
|
||||
session,
|
||||
};
|
||||
};
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create({
|
||||
transformer: superjson,
|
||||
});
|
||||
|
||||
export const createTRPCRouter = t.router;
|
||||
export const createCallerFactory = t.createCallerFactory;
|
||||
|
||||
const withDatabaseMiddleware = t.middleware(async (opts) => {
|
||||
return withDatabase({
|
||||
ctx: opts.ctx,
|
||||
next: opts.next,
|
||||
});
|
||||
});
|
||||
|
||||
const withAutenticationMiddleware = t.middleware(async (opts) => {
|
||||
return withAuthentication({
|
||||
ctx: opts.ctx,
|
||||
next: opts.next,
|
||||
});
|
||||
});
|
||||
|
||||
export const publicProcedure = t.procedure.use(withDatabaseMiddleware);
|
||||
|
||||
export const protectedProcedure = t.procedure
|
||||
.use(withDatabaseMiddleware)
|
||||
.use(withAutenticationMiddleware) // NOTE: This is needed to ensure that the teamId is set in the context
|
||||
.use(async (opts) => {
|
||||
const { session } = opts.ctx;
|
||||
|
||||
// if (!session) {
|
||||
// throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
// }
|
||||
|
||||
return opts.next({
|
||||
ctx: {
|
||||
session,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Database } from "@basango/db/client";
|
||||
|
||||
// import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { Session } from "@/utils/auth";
|
||||
|
||||
export const withAuthentication = async <TReturn>(opts: {
|
||||
ctx: {
|
||||
session?: Session | null;
|
||||
db: Database;
|
||||
};
|
||||
next: (opts: {
|
||||
ctx: {
|
||||
session?: Session | null;
|
||||
db: Database;
|
||||
};
|
||||
}) => Promise<TReturn>;
|
||||
}) => {
|
||||
const { ctx, next } = opts;
|
||||
|
||||
// const userId = ctx.session?.user?.id;
|
||||
|
||||
// if (!userId) {
|
||||
// throw new TRPCError({
|
||||
// code: "UNAUTHORIZED",
|
||||
// message: "No permission to access",
|
||||
// });
|
||||
// }
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
db: ctx.db,
|
||||
session: ctx.session,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { type Database, db } from "@basango/db/client";
|
||||
|
||||
import type { Session } from "@/utils/auth";
|
||||
|
||||
export const withDatabase = async <TReturn>(opts: {
|
||||
ctx: {
|
||||
session?: Session | null;
|
||||
db: Database;
|
||||
};
|
||||
next: (opts: {
|
||||
ctx: {
|
||||
session?: Session | null;
|
||||
db: Database;
|
||||
};
|
||||
}) => Promise<TReturn>;
|
||||
}) => {
|
||||
const { ctx, next } = opts;
|
||||
|
||||
ctx.db = db;
|
||||
const result = await next({ ctx });
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
|
||||
|
||||
import { createTRPCRouter } from "@/trpc/init";
|
||||
import { sourcesRouter } from "@/trpc/routers/sources";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
sources: sourcesRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
export type AppRouter = typeof appRouter;
|
||||
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||
export type RouterInputs = inferRouterInputs<AppRouter>;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createSource, getSourceById, getSources, updateSource } from "@basango/db/queries";
|
||||
|
||||
import { createSourceSchema, getSourceSchema, updateSourceSchema } from "@/schemas/sources";
|
||||
import { createTRPCRouter, protectedProcedure } from "@/trpc/init";
|
||||
|
||||
export const sourcesRouter = createTRPCRouter({
|
||||
create: protectedProcedure.input(createSourceSchema).mutation(async ({ ctx, input }) => {
|
||||
return createSource(ctx.db, { ...input });
|
||||
}),
|
||||
|
||||
get: protectedProcedure.query(async ({ ctx }) => getSources(ctx.db)),
|
||||
|
||||
getById: protectedProcedure.input(getSourceSchema).query(async ({ ctx, input }) => {
|
||||
return getSourceById(ctx.db, { ...input });
|
||||
}),
|
||||
|
||||
update: protectedProcedure.input(updateSourceSchema).mutation(async ({ ctx, input }) => {
|
||||
return updateSource(ctx.db, { ...input });
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { type JWTPayload, jwtVerify } from "jose";
|
||||
|
||||
import { env } from "@/config";
|
||||
|
||||
export type Session = {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type VerifiedJWTPayload = JWTPayload & {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function verifyAccessToken(accessToken?: string): Promise<Session | null> {
|
||||
if (!accessToken) return null;
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify<VerifiedJWTPayload>(
|
||||
accessToken,
|
||||
new TextEncoder().encode(env("BASANGO_JWT_SECRET")),
|
||||
);
|
||||
|
||||
return {
|
||||
user: {
|
||||
email: payload.user.email,
|
||||
full_name: payload.user.full_name,
|
||||
id: payload.user.id,
|
||||
},
|
||||
};
|
||||
} catch (_error: unknown) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,22 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@basango/ui"],
|
||||
devIndicators: false,
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "DENY",
|
||||
},
|
||||
],
|
||||
source: "/((?!api/proxy).*)",
|
||||
},
|
||||
];
|
||||
},
|
||||
poweredByHeader: false,
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ["@basango/ui", "@basango/api"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@basango/api": "workspace:*",
|
||||
"@basango/ui": "workspace:*",
|
||||
"@date-fns/tz": "^1.4.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@trpc/client": "^11.7.1",
|
||||
"@trpc/tanstack-react-query": "^11.7.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "catalog:",
|
||||
"next-international": "^1.3.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.7.3",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"superjson": "^2.2.5",
|
||||
"zod": "^4.1.12",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@basango/tsconfig": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="bg-muted/50 aspect-video rounded-xl" />
|
||||
<div className="bg-muted/50 aspect-video rounded-xl" />
|
||||
<div className="bg-muted/50 aspect-video rounded-xl" />
|
||||
</div>
|
||||
<div className="bg-muted/50 min-h-screen flex-1 rounded-xl md:min-h-min" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@basango/ui/components/breadcrumb";
|
||||
import { Separator } from "@basango/ui/components/separator";
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@basango/ui/components/sidebar";
|
||||
|
||||
import { AppSidebar } from "@/components/sidebar/app-sidebar";
|
||||
import { HydrateClient } from "@/trpc/server";
|
||||
|
||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<HydrateClient>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator className="mr-2 data-[orientation=vertical]:h-4" orientation="vertical" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink href="#">Building Your Application</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</HydrateClient>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="bg-muted/50 aspect-video rounded-xl" />
|
||||
<div className="bg-muted/50 aspect-video rounded-xl" />
|
||||
<div className="bg-muted/50 aspect-video rounded-xl" />
|
||||
</div>
|
||||
<div className="bg-muted/50 min-h-screen flex-1 rounded-xl md:min-h-min" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { GalleryVerticalEnd } from "lucide-react";
|
||||
|
||||
import { LoginForm } from "@/components/forms/login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||
<div className="flex justify-center gap-2 md:justify-start">
|
||||
<a className="flex items-center gap-2 font-medium" href="#">
|
||||
<div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
|
||||
<GalleryVerticalEnd className="size-4" />
|
||||
</div>
|
||||
Acme Inc.
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-xs">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted relative hidden lg:block">
|
||||
<img
|
||||
alt="verification placeholder"
|
||||
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
|
||||
src="/placeholder.svg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@basango/ui/components/button";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ErrorPage({ reset }: { reset: () => void }) {
|
||||
return (
|
||||
<div className="h-[calc(100vh-200px)] w-full">
|
||||
<div className="mt-8 flex flex-col items-center justify-center h-full">
|
||||
<div className="flex justify-between items-center flex-col mt-8 text-center mb-8">
|
||||
<h2 className="font-medium mb-4">Something went wrong</h2>
|
||||
<p className="text-sm text-[#878787]">
|
||||
An unexpected error has occurred. Please try again
|
||||
<br /> or contact support if the issue persists.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<Button onClick={() => reset()} variant="outline">
|
||||
Try again
|
||||
</Button>
|
||||
|
||||
<Link href="/account/support">
|
||||
<Button>Contact us</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "@basango/ui/globals.css";
|
||||
|
||||
import { Toaster } from "@basango/ui/components/sonner";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
|
||||
import { Providers } from "./providers";
|
||||
|
||||
const geistSans = Geist({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-sans",
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-mono",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
description: "Basango : The intelligent news curation platform.",
|
||||
metadataBase: new URL("https://dashboard.basango.com"),
|
||||
title: "Basango | AI-powered news curation dashboard",
|
||||
};
|
||||
|
||||
export const viewport = {
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)" },
|
||||
{ media: "(prefers-color-scheme: dark)" },
|
||||
],
|
||||
userScalable: false,
|
||||
width: "device-width",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
params,
|
||||
children,
|
||||
}: Readonly<{
|
||||
params: Promise<{ locale: string }>;
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const { locale } = await params;
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<NuqsAdapter>
|
||||
<Providers locale={locale}>{children}</Providers>
|
||||
<Toaster />
|
||||
</NuqsAdapter>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="h-screen flex flex-col items-center justify-center text-center text-sm text-[#606060]">
|
||||
<h2 className="text-xl font-semibold mb-2">Not Found</h2>
|
||||
<p className="mb-4">Could not find requested resource</p>
|
||||
<Link className="underline" href="/">
|
||||
Return Home
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { I18nProviderClient } from "@/locales/client";
|
||||
import { TRPCReactProvider } from "@/trpc/client";
|
||||
|
||||
type ProviderProps = {
|
||||
locale: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Providers({ locale, children }: ProviderProps) {
|
||||
return (
|
||||
<TRPCReactProvider>
|
||||
<I18nProviderClient locale={locale}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
disableTransitionOnChange
|
||||
enableColorScheme
|
||||
enableSystem
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</I18nProviderClient>
|
||||
</TRPCReactProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import NextError from "next/error";
|
||||
|
||||
export default function GlobalError() {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<NextError statusCode={0} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "@basango/ui/global.css";
|
||||
|
||||
import { Providers } from "@/components/providers";
|
||||
|
||||
const geistSans = Geist({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-sans",
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-mono",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
description: "Basango : The intelligent news curation platform.",
|
||||
title: "Basango Dashboard",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Button } from "@basango/ui/components/button";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
alt="Next.js logo"
|
||||
className="dark:invert"
|
||||
height={20}
|
||||
priority
|
||||
src="/next.svg"
|
||||
width={100}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Image
|
||||
alt="Vercel logomark"
|
||||
className="dark:invert"
|
||||
height={16}
|
||||
src="/vercel.svg"
|
||||
width={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<Button variant="secondary">test component</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Button } from "@basango/ui/components/button";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
} from "@basango/ui/components/field";
|
||||
import { Input } from "@basango/ui/components/input";
|
||||
import { cn } from "@basango/ui/lib/utils";
|
||||
|
||||
export function LoginForm({ className, ...props }: React.ComponentProps<"form">) {
|
||||
return (
|
||||
<form className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<FieldGroup>
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<h1 className="text-2xl font-bold">Login to your account</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
Enter your email below to login to your account
|
||||
</p>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" placeholder="m@example.com" required type="email" />
|
||||
</Field>
|
||||
<Field>
|
||||
<div className="flex items-center">
|
||||
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||
<a className="ml-auto text-sm underline-offset-4 hover:underline" href="#">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
<Input id="password" required type="password" />
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit">Login</Button>
|
||||
</Field>
|
||||
<FieldSeparator>Or continue with</FieldSeparator>
|
||||
<Field>
|
||||
<Button type="button" variant="outline">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Login with GitHub
|
||||
</Button>
|
||||
<FieldDescription className="text-center">
|
||||
Don't have an account?{" "}
|
||||
<a className="underline underline-offset-4" href="#">
|
||||
Sign up
|
||||
</a>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import * as React from "react";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
disableTransitionOnChange
|
||||
enableColorScheme
|
||||
enableSystem
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarRail,
|
||||
} from "@basango/ui/components/sidebar";
|
||||
import {
|
||||
AudioWaveform,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Command,
|
||||
Frame,
|
||||
GalleryVerticalEnd,
|
||||
MapIcon,
|
||||
PieChart,
|
||||
Settings2,
|
||||
SquareTerminal,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { NavMain } from "./nav-main";
|
||||
import { NavProjects } from "./nav-projects";
|
||||
import { NavUser } from "./nav-user";
|
||||
import { TeamSwitcher } from "./team-switcher";
|
||||
|
||||
const data = {
|
||||
navMain: [
|
||||
{
|
||||
icon: SquareTerminal,
|
||||
isActive: true,
|
||||
items: [
|
||||
{
|
||||
title: "History",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Starred",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
title: "Playground",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
icon: Bot,
|
||||
items: [
|
||||
{
|
||||
title: "Genesis",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Explorer",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Quantum",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
title: "Models",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
icon: BookOpen,
|
||||
items: [
|
||||
{
|
||||
title: "Introduction",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Get Started",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Tutorials",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Changelog",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
title: "Documentation",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
icon: Settings2,
|
||||
items: [
|
||||
{
|
||||
title: "General",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Team",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Billing",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Limits",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
title: "Settings",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
icon: Frame,
|
||||
name: "Design Engineering",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
icon: PieChart,
|
||||
name: "Sales & Marketing",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
icon: MapIcon,
|
||||
name: "Travel",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
teams: [
|
||||
{
|
||||
logo: GalleryVerticalEnd,
|
||||
name: "Acme Inc",
|
||||
plan: "Enterprise",
|
||||
},
|
||||
{
|
||||
logo: AudioWaveform,
|
||||
name: "Acme Corp.",
|
||||
plan: "Startup",
|
||||
},
|
||||
{
|
||||
logo: Command,
|
||||
name: "Evil Corp.",
|
||||
plan: "Free",
|
||||
},
|
||||
],
|
||||
user: {
|
||||
avatar: "/avatars/shadcn.jpg",
|
||||
email: "m@example.com",
|
||||
name: "shadcn",
|
||||
},
|
||||
};
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
<SidebarHeader>
|
||||
<TeamSwitcher teams={data.teams} />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={data.navMain} />
|
||||
<NavProjects projects={data.projects} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser user={data.user} />
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@basango/ui/components/collapsible";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@basango/ui/components/sidebar";
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: LucideIcon;
|
||||
isActive?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}[];
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<Collapsible
|
||||
asChild
|
||||
className="group/collapsible"
|
||||
defaultOpen={item.isActive}
|
||||
key={item.title}
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={item.title}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<a href={subItem.url}>
|
||||
<span>{subItem.title}</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@basango/ui/components/dropdown-menu";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@basango/ui/components/sidebar";
|
||||
import { Folder, Forward, type LucideIcon, MoreHorizontal, Trash2 } from "lucide-react";
|
||||
|
||||
export function NavProjects({
|
||||
projects,
|
||||
}: {
|
||||
projects: {
|
||||
name: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
}[];
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Projects</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{projects.map((item) => (
|
||||
<SidebarMenuItem key={item.name}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.name}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction showOnHover>
|
||||
<MoreHorizontal />
|
||||
<span className="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={isMobile ? "end" : "start"}
|
||||
className="w-48 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<Folder className="text-muted-foreground" />
|
||||
<span>View Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Forward className="text-muted-foreground" />
|
||||
<span>Share Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>Delete Project</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="text-sidebar-foreground/70">
|
||||
<MoreHorizontal className="text-sidebar-foreground/70" />
|
||||
<span>More</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@basango/ui/components/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@basango/ui/components/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@basango/ui/components/sidebar";
|
||||
import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogOut, Sparkles } from "lucide-react";
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
}: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
size="lg"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage alt={user.name} src={user.avatar} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage alt={user.name} src={user.avatar} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Sparkles />
|
||||
Upgrade to Pro
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<BadgeCheck />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CreditCard />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Bell />
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOut />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@basango/ui/components/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@basango/ui/components/sidebar";
|
||||
import { ChevronsUpDown, Plus } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
export function TeamSwitcher({
|
||||
teams,
|
||||
}: {
|
||||
teams: {
|
||||
name: string;
|
||||
logo: React.ElementType;
|
||||
plan: string;
|
||||
}[];
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
const [activeTeam, setActiveTeam] = React.useState(teams[0]);
|
||||
|
||||
if (!activeTeam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
size="lg"
|
||||
>
|
||||
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
|
||||
<activeTeam.logo className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{activeTeam.name}</span>
|
||||
<span className="truncate text-xs">{activeTeam.plan}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="text-muted-foreground text-xs">Teams</DropdownMenuLabel>
|
||||
{teams.map((team, index) => (
|
||||
<DropdownMenuItem
|
||||
className="gap-2 p-2"
|
||||
key={team.name}
|
||||
onClick={() => setActiveTeam(team)}
|
||||
>
|
||||
<div className="flex size-6 items-center justify-center rounded-md border">
|
||||
<team.logo className="size-3.5 shrink-0" />
|
||||
</div>
|
||||
{team.name}
|
||||
<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="gap-2 p-2">
|
||||
<div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
|
||||
<Plus className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground font-medium">Add team</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { TZDate } from "@date-fns/tz";
|
||||
import { eachDayOfInterval, endOfMonth, endOfWeek, startOfMonth, startOfWeek } from "date-fns";
|
||||
|
||||
export function useCalendarDates(currentDate: Date, weekStartsOnMonday: boolean) {
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
const calendarStart = startOfWeek(monthStart, {
|
||||
weekStartsOn: weekStartsOnMonday ? 1 : 0,
|
||||
});
|
||||
const calendarEnd = endOfWeek(monthEnd, {
|
||||
weekStartsOn: weekStartsOnMonday ? 1 : 0,
|
||||
});
|
||||
const calendarDays = eachDayOfInterval({
|
||||
end: calendarEnd,
|
||||
start: calendarStart,
|
||||
}).map((date) => new TZDate(date, "UTC"));
|
||||
const firstWeek = eachDayOfInterval({
|
||||
end: endOfWeek(calendarStart, { weekStartsOn: weekStartsOnMonday ? 1 : 0 }),
|
||||
start: calendarStart,
|
||||
}).map((date) => new TZDate(date, "UTC"));
|
||||
|
||||
return {
|
||||
calendarDays,
|
||||
calendarEnd,
|
||||
calendarStart,
|
||||
firstWeek,
|
||||
monthEnd,
|
||||
monthStart,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
// Original useLocalStorage hook (useState-like API)
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T,
|
||||
): [T, (value: T | ((val: T) => T)) => void] {
|
||||
// Initialize state with a function to handle SSR
|
||||
const [localState, setLocalState] = useState<T>(() => {
|
||||
// Check if we're in a browser environment
|
||||
if (typeof window === "undefined") {
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get from local storage by key
|
||||
const item = window.localStorage.getItem(key);
|
||||
// Parse stored json or if none return initialValue
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key "${key}":`, error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
// Return a wrapped version of useState's setter function that persists the new value to localStorage
|
||||
const handleSetState = useCallback(
|
||||
(value: T | ((val: T) => T)) => {
|
||||
try {
|
||||
// Allow value to be a function so we have same API as useState
|
||||
const valueToStore = value instanceof Function ? value(localState) : value;
|
||||
// Save state
|
||||
setLocalState(valueToStore);
|
||||
// Save to local storage
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
},
|
||||
[key, localState],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Handle storage changes in other tabs/windows
|
||||
function handleStorageChange(event: StorageEvent) {
|
||||
if (event.key === key && event.newValue) {
|
||||
setLocalState(JSON.parse(event.newValue));
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to storage changes
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
}
|
||||
|
||||
// Cleanup the event listener on component unmount
|
||||
return () => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("storage", handleStorageChange);
|
||||
}
|
||||
};
|
||||
}, [key]);
|
||||
|
||||
return [localState, handleSetState];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: This is a hook file */
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { type Resolver, type UseFormProps, useForm } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
|
||||
export const useZodForm = <T extends z.ZodType<any, any>>(
|
||||
schema: T,
|
||||
options?: Omit<UseFormProps<z.infer<T>>, "resolver">,
|
||||
) => {
|
||||
return useForm<z.infer<T>>({
|
||||
resolver: zodResolver(schema) as unknown as Resolver<z.infer<T>, any, z.infer<T>>,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { createI18nClient } from "next-international/client";
|
||||
|
||||
// NOTE: Also update middleware.ts to support locale
|
||||
export const languages = ["en"];
|
||||
|
||||
export const { useScopedI18n, I18nProviderClient, useCurrentLocale, useChangeLocale, useI18n } =
|
||||
createI18nClient({
|
||||
en: () => import("./translations/en"),
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createI18nServer } from "next-international/server";
|
||||
|
||||
export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer({
|
||||
en: () => import("./translations/en"),
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
app: "Basango Dashboard",
|
||||
} as const;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { createI18nMiddleware } from "next-international/middleware";
|
||||
|
||||
const I18nMiddleware = createI18nMiddleware({
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
urlMappingStrategy: "rewrite",
|
||||
});
|
||||
|
||||
export default async function proxy(request: NextRequest) {
|
||||
const response = await I18nMiddleware(request);
|
||||
|
||||
// const nextUrl = request.nextUrl;
|
||||
|
||||
// const pathnameLocale = nextUrl.pathname.split("/", 2)?.[1];
|
||||
|
||||
// // Remove the locale from the pathname
|
||||
// const pathnameWithoutLocale = pathnameLocale
|
||||
// ? nextUrl.pathname.slice(pathnameLocale.length + 1)
|
||||
// : nextUrl.pathname;
|
||||
|
||||
// // Create a new URL without the locale in the pathname
|
||||
// const newUrl = new URL(pathnameWithoutLocale || "/", request.url);
|
||||
// const encodedSearchParams = `${newUrl?.pathname?.substring(1)}${newUrl.search}`;
|
||||
// const session = request.cookies.get("token")?.value;
|
||||
|
||||
// // 1. Not authenticated
|
||||
// if (
|
||||
// !session &&
|
||||
// newUrl.pathname !== "/login" &&
|
||||
// !newUrl.pathname.includes("/i/") &&
|
||||
// !newUrl.pathname.includes("/s/") &&
|
||||
// !newUrl.pathname.includes("/verify")
|
||||
// ) {
|
||||
// const url = new URL("/login", request.url);
|
||||
|
||||
// if (encodedSearchParams) {
|
||||
// url.searchParams.append("return_to", encodedSearchParams);
|
||||
// }
|
||||
|
||||
// return NextResponse.redirect(url);
|
||||
// }
|
||||
|
||||
// If all checks pass, return the original or updated response
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import type { AppRouter } from "@basango/api/trpc/routers/_app";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { QueryClientProvider, isServer } from "@tanstack/react-query";
|
||||
import { createTRPCClient, httpBatchLink, loggerLink } from "@trpc/client";
|
||||
import { createTRPCContext } from "@trpc/tanstack-react-query";
|
||||
import { useState } from "react";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { makeQueryClient } from "./query-client";
|
||||
|
||||
export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
|
||||
|
||||
let browserQueryClient: QueryClient;
|
||||
|
||||
function getQueryClient() {
|
||||
if (isServer) {
|
||||
// Server: always make a new query client
|
||||
return makeQueryClient();
|
||||
}
|
||||
|
||||
// Browser: make a new query client if we don't already have one
|
||||
// This is very important, so we don't re-make a new client if React
|
||||
// suspends during the initial render. This may not be needed if we
|
||||
// have a suspense boundary BELOW the creation of the query client
|
||||
if (!browserQueryClient) browserQueryClient = makeQueryClient();
|
||||
|
||||
return browserQueryClient;
|
||||
}
|
||||
|
||||
export function TRPCReactProvider(
|
||||
props: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>,
|
||||
) {
|
||||
const queryClient = getQueryClient();
|
||||
const [trpcClient] = useState(() =>
|
||||
createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
async headers() {
|
||||
const token = window.localStorage.getItem("auth_token");
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
},
|
||||
transformer: superjson,
|
||||
url: `${process.env.NEXT_PUBLIC_API_URL}/trpc`,
|
||||
}),
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
process.env.NODE_ENV === "development" ||
|
||||
(opts.direction === "down" && opts.result instanceof Error),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TRPCProvider queryClient={queryClient} trpcClient={trpcClient}>
|
||||
{props.children}
|
||||
</TRPCProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { QueryClient, defaultShouldDehydrateQuery } from "@tanstack/react-query";
|
||||
import superjson from "superjson";
|
||||
|
||||
export function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
dehydrate: {
|
||||
serializeData: superjson.serialize,
|
||||
shouldDehydrateQuery: (query) =>
|
||||
defaultShouldDehydrateQuery(query) || query.state.status === "pending",
|
||||
},
|
||||
hydrate: {
|
||||
deserializeData: superjson.deserialize,
|
||||
},
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import "server-only";
|
||||
|
||||
import type { AppRouter } from "@basango/api/trpc/routers/_app";
|
||||
//import { getCountryCode, getLocale, getTimezone } from "@basango/location";
|
||||
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
|
||||
import { createTRPCClient, httpBatchLink, loggerLink } from "@trpc/client";
|
||||
import { type TRPCQueryOptions, createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
|
||||
import { cache } from "react";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { makeQueryClient } from "./query-client";
|
||||
|
||||
// IMPORTANT: Create a stable getter for the query client that
|
||||
// will return the same client during the same request.
|
||||
export const getQueryClient = cache(makeQueryClient);
|
||||
|
||||
export const trpc = createTRPCOptionsProxy<AppRouter>({
|
||||
client: createTRPCClient({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
async headers() {
|
||||
const token = window.localStorage.getItem("auth_token");
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
// "x-user-country": await getCountryCode(),
|
||||
// "x-user-locale": await getLocale(),
|
||||
// "x-user-timezone": await getTimezone(),
|
||||
};
|
||||
},
|
||||
transformer: superjson,
|
||||
url: `${process.env.NEXT_PUBLIC_API_URL}/trpc`,
|
||||
}),
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
process.env.NODE_ENV === "development" ||
|
||||
(opts.direction === "down" && opts.result instanceof Error),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
queryClient: getQueryClient,
|
||||
});
|
||||
|
||||
export function HydrateClient(props: { children: React.ReactNode }) {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
return <HydrationBoundary state={dehydrate(queryClient)}>{props.children}</HydrationBoundary>;
|
||||
}
|
||||
|
||||
export function prefetch<T extends ReturnType<TRPCQueryOptions<AppRouter>>>(queryOptions: T) {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
if (queryOptions.queryKey[1]?.type === "infinite") {
|
||||
void queryClient.prefetchInfiniteQuery(
|
||||
queryOptions as unknown as Parameters<typeof queryClient.prefetchInfiniteQuery>[0],
|
||||
);
|
||||
} else {
|
||||
void queryClient.prefetchQuery(
|
||||
queryOptions as unknown as Parameters<typeof queryClient.prefetchQuery>[0],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function batchPrefetch<T extends ReturnType<TRPCQueryOptions<AppRouter>>>(
|
||||
queryOptionsArray: T[],
|
||||
) {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
for (const queryOptions of queryOptionsArray) {
|
||||
if (queryOptions.queryKey[1]?.type === "infinite") {
|
||||
void queryClient.prefetchInfiniteQuery(
|
||||
queryOptions as unknown as Parameters<typeof queryClient.prefetchInfiniteQuery>[0],
|
||||
);
|
||||
} else {
|
||||
void queryClient.prefetchQuery(
|
||||
queryOptions as unknown as Parameters<typeof queryClient.prefetchQuery>[0],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
export const colors = [
|
||||
"#FF6900", // Orange
|
||||
"#FCB900", // Yellow
|
||||
"#00D084", // Emerald
|
||||
"#8ED1FC", // Sky Blue
|
||||
"#0693E3", // Blue
|
||||
"#ABB8C3", // Gray
|
||||
"#EB144C", // Red
|
||||
"#F78DA7", // Pink
|
||||
"#9900EF", // Purple
|
||||
"#0079BF", // Dark Blue
|
||||
"#B6BBBF", // Light Gray
|
||||
"#FF5A5F", // Coral
|
||||
"#F7C59F", // Peach
|
||||
"#8492A6", // Slate
|
||||
"#4D5055", // Charcoal
|
||||
"#AF5A50", // Terracotta
|
||||
"#F9D6E7", // Pale Pink
|
||||
"#B5EAEA", // Pale Cyan
|
||||
"#B388EB", // Lavender
|
||||
"#B04632", // Rust
|
||||
"#FF78CB", // Pink
|
||||
"#4E5A65", // Gray
|
||||
"#01FF70", // Lime
|
||||
"#85144b", // Pink
|
||||
"#F012BE", // Purple
|
||||
"#7FDBFF", // Sky Blue
|
||||
"#3D9970", // Olive
|
||||
"#AAAAAA", // Silver
|
||||
"#111111", // Black
|
||||
"#0074D9", // Blue
|
||||
"#39CCCC", // Teal
|
||||
"#001f3f", // Navy
|
||||
"#FF9F1C", // Orange
|
||||
"#5E6A71", // Ash
|
||||
"#75D701", // Neon Green
|
||||
"#B6C8A9", // Lichen
|
||||
"#00A9FE", // Electric Blue
|
||||
"#EAE8E1", // Bone
|
||||
"#CD346C", // Raspberry
|
||||
"#FF6FA4", // Pink Sherbet
|
||||
"#D667FB", // Purple Mountain Majesty
|
||||
"#0080FF", // Azure
|
||||
"#656D78", // Dim Gray
|
||||
"#F8842C", // Tangerine
|
||||
"#FF8CFF", // Carnation Pink
|
||||
"#647F6A", // Feldgrau
|
||||
"#5E574E", // Field Drab
|
||||
"#EF5466", // KU Crimson
|
||||
"#B0E0E6", // Powder Blue
|
||||
"#EB5E7C", // Rose Pink
|
||||
"#8A2BE2", // Blue Violet
|
||||
"#6B7C85", // Slate Gray
|
||||
"#8C92AC", // Lavender Blue
|
||||
"#6C587A", // Eminence
|
||||
"#52A1FF", // Azureish White
|
||||
"#32CD32", // Lime Green
|
||||
"#E04F9F", // Orchid Pink
|
||||
"#915C83", // Lilac Bush
|
||||
"#4C6B88", // Air Force Blue
|
||||
"#587376", // Cadet Blue
|
||||
"#C46210", // Buff
|
||||
"#65B0D0", // Columbia Blue
|
||||
"#2F4F4F", // Dark Slate Gray
|
||||
"#528B8B", // Dark Cyan
|
||||
"#8B4513", // Saddle Brown
|
||||
"#4682B4", // Steel Blue
|
||||
"#CD853F", // Peru
|
||||
"#FFA07A", // Light Salmon
|
||||
"#CD5C5C", // Indian Red
|
||||
"#483D8B", // Dark Slate Blue
|
||||
"#696969", // Dim Gray
|
||||
];
|
||||
|
||||
export function customHash(value: string) {
|
||||
let hash = 0;
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = (hash << 5) + value.charCodeAt(i);
|
||||
hash = hash & hash;
|
||||
}
|
||||
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function getColor(value: string, arrayLength: number) {
|
||||
const hashValue = customHash(value);
|
||||
const index = hashValue % arrayLength;
|
||||
return index;
|
||||
}
|
||||
|
||||
export function getColorFromName(value: string) {
|
||||
const index = getColor(value, colors.length);
|
||||
|
||||
return colors[index];
|
||||
}
|
||||
|
||||
export function getRandomColor() {
|
||||
const randomIndex = Math.floor(Math.random() * colors.length);
|
||||
return colors[randomIndex];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function getUrl() {
|
||||
if (process.env.NEXT_PUBLIC_URL) {
|
||||
return process.env.NEXT_PUBLIC_URL;
|
||||
}
|
||||
|
||||
if (process.env.VERCEL_TARGET_ENV === "preview") {
|
||||
return `https://${process.env.VERCEL_URL}`;
|
||||
}
|
||||
|
||||
return "http://localhost:3000";
|
||||
}
|
||||
@@ -13,5 +13,12 @@
|
||||
},
|
||||
"exclude": ["node_modules"],
|
||||
"extends": "@basango/tsconfig/nextjs.json",
|
||||
"include": ["next-env.d.ts", "next.config.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"next.config.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"../../packages/ui/src/hooks/use-mobile.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user