feat(dashboard): setting up layout

This commit is contained in:
2025-11-12 16:51:59 +02:00
parent b8b2a15ee9
commit a3f46b6b38
61 changed files with 2957 additions and 123 deletions
+2 -1
View File
@@ -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
View File
@@ -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": {
+1
View File
@@ -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"),
},
+11
View File
@@ -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: {
+69
View File
@@ -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,
});
+67
View File
@@ -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,
},
});
});
+36
View File
@@ -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,
},
});
};
+23
View File
@@ -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;
};
+13
View File
@@ -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>;
+20
View File
@@ -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 });
}),
});
+40
View File
@@ -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;
}
}
+17 -1
View File
@@ -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;
+16 -1
View File
@@ -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>
);
}
+30
View File
@@ -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>
);
}
+13
View File
@@ -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>
);
}
-34
View File
@@ -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>
);
}
-59
View File
@@ -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&apos;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];
}
+14
View File
@@ -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,
});
};
+11
View File
@@ -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"),
});
+5
View File
@@ -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;
+50
View File
@@ -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).*)"],
};
+67
View File
@@ -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>
);
}
+20
View File
@@ -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,
},
},
});
}
+80
View File
@@ -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],
);
}
}
}
+101
View File
@@ -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];
}
+11
View File
@@ -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";
}
+8 -1
View File
@@ -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"
]
}