├── src ├── lib │ ├── types │ │ └── index.ts │ ├── hooks │ │ ├── index.ts │ │ ├── use-media-query.ts │ │ ├── use-usage.ts │ │ └── use-router-stuff.ts │ ├── functions │ │ ├── trim.ts │ │ ├── index.ts │ │ ├── capitalize.ts │ │ ├── get-platform.ts │ │ ├── fetch-with-timeout.ts │ │ └── log.ts │ ├── db-schema │ │ ├── index.ts │ │ ├── collection.schema.ts │ │ ├── workspace.schema.ts │ │ └── team.schema.ts │ ├── auth │ │ ├── index.ts │ │ ├── session-provider.tsx │ │ └── utils.ts │ ├── zod │ │ ├── index.ts │ │ └── schemas │ │ │ ├── index.ts │ │ │ ├── misc.ts │ │ │ ├── invite-member.ts │ │ │ └── team-member.ts │ ├── api │ │ ├── index.ts │ │ ├── collection.ts │ │ └── workspace.ts │ ├── analytics │ │ ├── context.ts │ │ ├── validation.ts │ │ ├── page-view-tracker.tsx │ │ ├── session-manager.tsx │ │ ├── identity.ts │ │ ├── retryManager.ts │ │ └── provider.tsx │ ├── constants │ │ ├── index.ts │ │ └── framer-motion.ts │ ├── swr │ │ ├── index.ts │ │ ├── use-users.ts │ │ ├── use-team-invite.ts │ │ └── use-workspace-users.ts │ ├── url.ts │ ├── middleware │ │ └── api.ts │ ├── stripe │ │ ├── client.ts │ │ └── index.ts │ ├── openapi │ │ ├── collection │ │ │ ├── index.ts │ │ │ ├── get-collections.ts │ │ │ ├── reorder-item.ts │ │ │ └── create-collection.ts │ │ ├── teams │ │ │ ├── index.ts │ │ │ ├── get-teams.ts │ │ │ ├── create-team.ts │ │ │ ├── get-team-info.ts │ │ │ └── update-a team.ts │ │ ├── workspace │ │ │ ├── index.ts │ │ │ ├── get-workspaces.ts │ │ │ ├── delete-workspace.ts │ │ │ ├── get-workspace-info.ts │ │ │ ├── update-a-workspace.ts │ │ │ └── create-workspace.ts │ │ └── responses.ts │ ├── actions.ts │ ├── fetcher.ts │ ├── paddle │ │ └── paddle-client.ts │ └── utility │ │ └── construct-metadata.ts ├── app │ ├── (dashboard) │ │ ├── [team_slug] │ │ │ ├── [workspace_slug] │ │ │ │ ├── providers.tsx │ │ │ │ ├── [collection_slug] │ │ │ │ │ ├── [item_slug] │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ └── loading.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── loading.tsx │ │ │ │ ├── settings │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── people │ │ │ │ │ │ ├── loading.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── loading.tsx │ │ │ ├── page.tsx │ │ │ ├── settings │ │ │ │ ├── billing │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── loading.tsx │ │ │ │ ├── loading.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── people │ │ │ │ │ └── loading.tsx │ │ │ ├── layout.tsx │ │ │ ├── loading.tsx │ │ │ ├── add-example │ │ │ │ └── page.tsx │ │ │ └── auth.tsx │ │ ├── page.tsx │ │ ├── settings │ │ │ ├── page.tsx │ │ │ ├── tokens │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── loading.tsx │ │ ├── create │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── providers.tsx │ ├── api │ │ ├── callback │ │ │ ├── route.ts │ │ │ └── stripe │ │ │ │ └── route.ts │ │ ├── route.ts │ │ ├── teams │ │ │ └── [team_slug] │ │ │ │ ├── billing │ │ │ │ ├── manage │ │ │ │ │ └── route.ts │ │ │ │ ├── upgrade │ │ │ │ │ └── route.ts │ │ │ │ └── cancel-subscription │ │ │ │ │ └── route.ts │ │ │ │ ├── invites │ │ │ │ └── reset │ │ │ │ │ └── route.ts │ │ │ │ └── logo │ │ │ │ └── route.ts │ │ ├── auth │ │ │ └── account-exists │ │ │ │ └── route.ts │ │ ├── upload │ │ │ └── route.ts │ │ └── user │ │ │ └── tokens │ │ │ └── route.ts │ ├── loading.tsx │ ├── (auth) │ │ ├── login │ │ │ ├── loading.tsx │ │ │ ├── credential │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── signup │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ └── layout.tsx │ └── layout.tsx ├── styles │ ├── Inter-Medium.ttf │ ├── Satoshi-Black.ttf │ ├── Satoshi-Bold.ttf │ ├── Satoshi-Variable.woff2 │ └── font.ts ├── components │ ├── molecule │ │ ├── index.tsx │ │ ├── session.tsx │ │ ├── usage-limit-view.tsx │ │ ├── team-permission-view.tsx │ │ ├── workspace-permission-view.tsx │ │ └── text-field.tsx │ ├── ui │ │ ├── models │ │ │ └── index.ts │ │ ├── icons │ │ │ ├── index.tsx │ │ │ ├── check-circle-fill.tsx │ │ │ ├── github.tsx │ │ │ ├── loading-circle.tsx │ │ │ ├── font-default.tsx │ │ │ └── font-mono.tsx │ │ ├── theme-provider.tsx │ │ ├── divider.tsx │ │ ├── index.tsx │ │ ├── progress-bar.tsx │ │ ├── separator.tsx │ │ ├── input.tsx │ │ ├── toaster.tsx │ │ ├── editor │ │ │ └── selectors │ │ │ │ ├── math-selectors.tsx │ │ │ │ └── text-buttons.tsx │ │ ├── loading-spinner.tsx │ │ ├── switch.tsx │ │ ├── badge.tsx │ │ ├── popover.tsx │ │ ├── copy-button.tsx │ │ ├── account │ │ │ └── delete-account.tsx │ │ ├── avatar.tsx │ │ ├── background.tsx │ │ ├── model.tsx │ │ └── popover-2.tsx │ ├── layout │ │ ├── loyout-loader.tsx │ │ ├── max-width-wrapper.tsx │ │ ├── nav-layout.tsx │ │ ├── settings-nav-link.tsx │ │ ├── teams-nav-tabs.tsx │ │ └── settings-layout.tsx │ ├── atom │ │ ├── icon-menu.tsx │ │ ├── spinner.tsx │ │ ├── tab.tsx │ │ └── logo.tsx │ ├── drag-n-drop │ │ ├── column-context.tsx │ │ ├── data.ts │ │ ├── board.tsx │ │ ├── registery.ts │ │ └── board-context.tsx │ ├── icons │ │ └── three-dots.tsx │ └── team │ │ ├── create │ │ ├── create-team.tsx │ │ └── create-team-form.tsx │ │ ├── workspace │ │ ├── empty-workspace-view.tsx │ │ ├── workspace-settings-dropdown.tsx │ │ ├── collection │ │ │ └── content │ │ │ │ ├── item │ │ │ │ └── create-item-cta.tsx │ │ │ │ └── content.tsx │ │ └── workspace-view.tsx │ │ ├── team-not-found.tsx │ │ └── invite │ │ └── accept │ │ └── accept-invite.tsx ├── pages │ └── api │ │ └── auth │ │ └── [...nextauth].ts └── middleware.ts ├── .eslintrc.json ├── .prettierignore ├── public ├── favicon.ico ├── _static │ ├── logo.png │ ├── grid.svg │ ├── slack-logo.svg │ └── logo.svg ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png └── site.webmanifest ├── types ├── user.type.ts └── next-auth.d.ts ├── postcss.config.js ├── turbo.json ├── prettier.config.js ├── next-env.d.ts ├── components.json ├── .vscode └── settings.json ├── scripts ├── get-api-users.ts ├── run.ts ├── team │ ├── update-missing-invite-code.ts │ ├── remove-non-attached-team-members.ts │ └── make-team-creator-owner-of-team.ts ├── workspace │ ├── generate-missing-slug.ts │ ├── assign-editor-to-workspaces.ts │ └── add-default-values.ts └── db │ └── create-indexes.ts ├── tsconfig.json ├── emails ├── component │ └── footer.tsx └── login-link.tsx ├── .gitignore ├── .env.example └── next.config.js /src/lib/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types"; -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/[workspace_slug]/providers.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | yarn.lock 3 | node_modules 4 | .next -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Orgnise/webapp/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Orgnise/webapp/HEAD/public/_static/logo.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Orgnise/webapp/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Orgnise/webapp/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /src/lib/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-router-stuff"; 2 | export * from "./use-media-query"; -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Orgnise/webapp/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /src/lib/functions/trim.ts: -------------------------------------------------------------------------------- 1 | export const trim = (u: unknown) => (typeof u === "string" ? u.trim() : u); 2 | -------------------------------------------------------------------------------- /src/styles/Inter-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Orgnise/webapp/HEAD/src/styles/Inter-Medium.ttf -------------------------------------------------------------------------------- /src/styles/Satoshi-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Orgnise/webapp/HEAD/src/styles/Satoshi-Black.ttf -------------------------------------------------------------------------------- /src/styles/Satoshi-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Orgnise/webapp/HEAD/src/styles/Satoshi-Bold.ttf -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Orgnise/webapp/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Orgnise/webapp/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/styles/Satoshi-Variable.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Orgnise/webapp/HEAD/src/styles/Satoshi-Variable.woff2 -------------------------------------------------------------------------------- /src/lib/db-schema/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./team.schema"; 2 | export * from "./workspace.schema"; 3 | export * from "./collection.schema"; -------------------------------------------------------------------------------- /src/lib/functions/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./fetch-with-timeout"; 2 | export * from "./log"; 3 | export * from "./trim"; 4 | export * from "./get-platform"; -------------------------------------------------------------------------------- /src/app/(dashboard)/page.tsx: -------------------------------------------------------------------------------- 1 | import DashboardClient from "./page-client"; 2 | 3 | export default function Dashboard() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./auth"; 2 | export * from "./session"; 3 | export * from "./team"; 4 | export * from "./utils"; 5 | export * from "./workspace"; -------------------------------------------------------------------------------- /src/lib/zod/index.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod"; 2 | import { extendZodWithOpenApi } from "zod-openapi"; 3 | 4 | extendZodWithOpenApi(z); 5 | 6 | export default z; 7 | -------------------------------------------------------------------------------- /src/lib/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./collection"; 2 | export * from "./errors"; 3 | export * from "./team"; 4 | export * from "./users"; 5 | export * from "./workspace"; -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import TeamsPageClient from "./page-client"; 2 | 3 | export default async function Teams() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/(dashboard)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import SettingsPageClient from "./page-client"; 2 | 3 | export default function SettingsPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/(dashboard)/settings/tokens/page.tsx: -------------------------------------------------------------------------------- 1 | import TokensPageClient from "./page-client"; 2 | 3 | export default function TokensPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /types/user.type.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id?: string; 3 | name?: string; 4 | email?: string; 5 | image?: string; 6 | createdAt?: string; 7 | updatedAt?: string; 8 | } 9 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-import": {}, 4 | "tailwindcss/nesting": {}, 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /src/lib/functions/capitalize.ts: -------------------------------------------------------------------------------- 1 | export function capitalize(str?: string | null) { 2 | if (!str || typeof str !== "string") return str; 3 | return str.charAt(0).toUpperCase() + str.slice(1); 4 | } 5 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "outputs": [".next/**", "!.next/cache/**"] 6 | }, 7 | "type-check": {} 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/molecule/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./session"; 2 | export * from "./team-permission-view"; 3 | export * from "./text-field"; 4 | export * from "./usage-limit-view"; 5 | export * from "./workspace-permission-view"; 6 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/[workspace_slug]/[collection_slug]/[item_slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import ItemPageClient from "./page-client"; 2 | 3 | export default async function CollectionContentPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/analytics/context.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createContext } from "react"; 4 | import { AnalyticsContextValue } from "./interfaces"; 5 | 6 | export const AnalyticsContext = createContext(null); -------------------------------------------------------------------------------- /src/lib/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./pricing"; 2 | export * from "./constants"; 3 | export * from "./framer-motion"; 4 | export * from "./team-role"; 5 | export * from "./workspace-role"; 6 | export * from "./stripe-country-short-names"; -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: true, 3 | semi: true, 4 | trailingComma: "all", 5 | printWidth: 80, 6 | tabWidth: 2, 7 | plugins: ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"], 8 | }; 9 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/[workspace_slug]/[collection_slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import CollectionContentPageClient from "./page-client"; 2 | 3 | export default async function CollectionContentPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/ui/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./create-workspace-modal"; 2 | export * from "./delete-account-modal"; 3 | export * from "./delete-token-modal"; 4 | export * from "./token-created-modal"; 5 | export * from "./add-workspace-members-modal"; -------------------------------------------------------------------------------- /src/lib/zod/schemas/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./misc"; 2 | export * from "./team-member"; 3 | export * from "./teams"; 4 | export * from "./workspaces"; 5 | export * from "./workspaces"; 6 | export * from "./invite-member"; 7 | export * from "./collection"; -------------------------------------------------------------------------------- /src/app/api/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | export const runtime = "edge"; 4 | 5 | export function GET() { 6 | 7 | return NextResponse.json({ 8 | message: "Hello from the callback route", 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/swr/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-collections"; 2 | export * from "./use-team"; 3 | export * from "./use-teams"; 4 | export * from "./use-team-invite"; 5 | export * from "./use-users"; 6 | export * from "./use-workspaces"; 7 | export * from "./use-workspace-users"; -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /src/lib/auth/session-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { SessionProvider } from "next-auth/react"; 3 | import { ReactNode } from "react"; 4 | 5 | export function AuthSessionProvider({ children }: { children: ReactNode }) { 6 | return {children}; 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/url.ts: -------------------------------------------------------------------------------- 1 | export const getSearchParams = (url: string) => { 2 | // Create a params object 3 | let params = {} as Record; 4 | 5 | new URL(url).searchParams.forEach(function (val, key) { 6 | params[key] = val; 7 | }); 8 | 9 | return params; 10 | }; 11 | -------------------------------------------------------------------------------- /src/components/molecule/session.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { SessionProvider } from "next-auth/react"; 3 | import { ReactNode } from "react"; 4 | 5 | export function AuthSessionProvider({ children }: { children: ReactNode }) { 6 | return {children}; 7 | } 8 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /src/components/layout/loyout-loader.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from "../ui/loading-spinner"; 2 | 3 | export default function LayoutLoader() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/components/ui/icons/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./check-circle-fill"; 2 | export { default as FontDefault } from "./font-default"; 3 | export { default as FontMono } from "./font-mono"; 4 | export { default as FontSerif } from "./font-serif"; 5 | export { default as Github } from "./github"; 6 | export * from "./loading-circle"; 7 | export * from "./magic"; 8 | -------------------------------------------------------------------------------- /src/components/ui/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 4 | import { type ThemeProviderProps } from "next-themes/dist/types"; 5 | 6 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 7 | return {children}; 8 | } 9 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import { NextAuthOptions } from "@/lib/auth/auth"; 2 | import NextAuth from "next-auth"; 3 | 4 | const handler = NextAuth(NextAuthOptions); 5 | 6 | export { handler as GET, handler as POST }; 7 | 8 | export default async function GET(req: any, res: any) { 9 | return NextAuth(req, res, NextAuthOptions); 10 | } 11 | -------------------------------------------------------------------------------- /src/app/api/route.ts: -------------------------------------------------------------------------------- 1 | import { openApiObject } from "@/lib/openapi"; 2 | import { NextResponse } from "next/server"; 3 | import { createDocument } from "zod-openapi"; 4 | 5 | export const runtime = "edge"; 6 | 7 | export function GET() { 8 | const doc = createDocument(openApiObject); 9 | return NextResponse.json(createDocument(openApiObject)); 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/zod/schemas/misc.ts: -------------------------------------------------------------------------------- 1 | import { roles } from "@/lib/constants/team-role"; 2 | import { plans } from "@/lib/types"; 3 | import z from "@/lib/zod"; 4 | 5 | export const planSchema = z.enum(plans).describe("The plan of the team."); 6 | 7 | export const roleSchema = z 8 | .enum(roles) 9 | .describe("The role of the authenticated user in the team."); 10 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/settings/billing/page.tsx: -------------------------------------------------------------------------------- 1 | import TeamBillingPageClient from "./billing-page-client"; 2 | 3 | export default function TeamBillingPage() { 4 | return ( 5 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Background } from "@/components/ui/background"; 2 | import { LoadingSpinner } from "@/components/ui/loading-spinner"; 3 | 4 | export default function Loading() { 5 | return ( 6 |
7 | 8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ReactNode } from "react"; 4 | import TeamAuth from "./auth"; 5 | import Providers from "./providers"; 6 | 7 | export default function TeamLayout({ children }: { children: ReactNode }) { 8 | return ( 9 | 10 | {children} 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/app/(auth)/login/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Background } from "@/components/ui/background"; 2 | import { LoadingSpinner } from "@/components/ui/loading-spinner"; 3 | 4 | export default function Loading() { 5 | return ( 6 |
7 | 8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/(dashboard)/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Background } from "@/components/ui/background"; 2 | import { LoadingSpinner } from "@/components/ui/loading-spinner"; 3 | 4 | export default function Loading() { 5 | return ( 6 |
7 | 8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/analytics/validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { eventSchemaMap, EventMap } from "./interfaces"; 3 | 4 | export const validateEvent = ( 5 | eventName: K, 6 | properties: EventMap[K] 7 | ): { success: boolean; error?: z.ZodError } => { 8 | const schema = eventSchemaMap[eventName]; 9 | return schema.safeParse(properties); 10 | }; -------------------------------------------------------------------------------- /src/app/(auth)/signup/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Background } from "@/components/ui/background"; 2 | import { LoadingSpinner } from "@/components/ui/loading-spinner"; 3 | 4 | export default function Loading() { 5 | return ( 6 |
7 | 8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/components/atom/icon-menu.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | interface MenuIconProps { 4 | icon: ReactNode; 5 | text: ReactNode; 6 | } 7 | 8 | export function IconMenu({ icon, text }: MenuIconProps) { 9 | return ( 10 |
11 | {icon} 12 |

{text}

13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/styles/font.ts: -------------------------------------------------------------------------------- 1 | import { Inter } from "next/font/google"; 2 | import localFont from "next/font/local"; 3 | 4 | export const satoshi = localFont({ 5 | src: "./Satoshi-Variable.woff2", 6 | variable: "--font-satoshi", 7 | weight: "300 900", 8 | display: "swap", 9 | style: "normal", 10 | }); 11 | 12 | export const inter = Inter({ 13 | variable: "--font-inter", 14 | subsets: ["latin"], 15 | }); 16 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/styles/global.css", 9 | "baseColor": "gray", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/(auth)/login/credential/page.tsx: -------------------------------------------------------------------------------- 1 | import { LoginWithCredentialsForm } from "./form"; 2 | 3 | export default function LoginPage() { 4 | return ( 5 |
6 |
7 | 8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "alphanum", 4 | "clsx", 5 | "deduping", 6 | "DICEBEAR", 7 | "gettask", 8 | "humburger", 9 | "Kanban", 10 | "listview", 11 | "lucide", 12 | "NEXTAUTH", 13 | "orgnise", 14 | "SIGNUP", 15 | "waitlist" 16 | ], 17 | "tailwindCSS.experimental.classRegex": [ 18 | ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/app/(dashboard)/create/page.tsx: -------------------------------------------------------------------------------- 1 | import { CreateTeam } from "@/components/team/create/create-team"; 2 | 3 | /** 4 | * Terms and conditions page 5 | */ 6 | export default function TermsAndConditionPage() { 7 | return ( 8 |
9 |
10 | 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Background } from "@/components/ui/background"; 2 | import { ReactNode } from "react"; 3 | 4 | export const runtime = "edge"; 5 | 6 | export default function AuthLayout({ children }: { children: ReactNode }) { 7 | return ( 8 | <> 9 | 10 |
11 | {children} 12 |
13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/middleware/api.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { parse } from "../utils"; 3 | 4 | export default function ApiMiddleware(req: NextRequest) { 5 | const { path, fullPath } = parse(req); 6 | 7 | // Note: we don't have to account for paths starting with `/api` 8 | // since they're automatically excluded via our middleware matcher 9 | return NextResponse.rewrite(new URL(`/api${fullPath}`, req.url)); 10 | } 11 | -------------------------------------------------------------------------------- /src/components/layout/max-width-wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { ReactNode } from "react"; 3 | 4 | export function MaxWidthWrapper({ 5 | className, 6 | children, 7 | }: { 8 | className?: string; 9 | children: ReactNode; 10 | }) { 11 | return ( 12 |
18 | {children} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/stripe/client.ts: -------------------------------------------------------------------------------- 1 | // Stripe Client SDK 2 | import { Stripe as StripeProps, loadStripe } from "@stripe/stripe-js"; 3 | 4 | let stripePromise: Promise; 5 | 6 | export const getStripe = () => { 7 | if (!stripePromise) { 8 | stripePromise = loadStripe( 9 | process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_LIVE ?? 10 | process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? 11 | "", 12 | ); 13 | } 14 | 15 | return stripePromise; 16 | }; 17 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/[workspace_slug]/[collection_slug]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export default function Layout({ children }: { children: ReactNode }) { 4 | return ( 5 |
9 |
10 | {children} 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/openapi/collection/index.ts: -------------------------------------------------------------------------------- 1 | import { ZodOpenApiPathsObject } from "zod-openapi"; 2 | import { createCollection } from "./create-collection"; 3 | import { getCollections } from "./get-collections"; 4 | import { reorderItem } from "./reorder-item"; 5 | 6 | 7 | export const collectionsPath: ZodOpenApiPathsObject = { 8 | "/collections": { 9 | get: getCollections, 10 | post: createCollection, 11 | }, 12 | "/collections/reorder": { 13 | post: reorderItem 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/lib/functions/get-platform.ts: -------------------------------------------------------------------------------- 1 | function getOS() { 2 | const platform = window.navigator.platform, 3 | macosPlatforms = ['macOS', 'Macintosh', 'MacIntel', 'MacPPC', 'Mac68K'], 4 | windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE']; 5 | 6 | let os = null; 7 | 8 | if (macosPlatforms.indexOf(platform) !== -1) { 9 | os = 'MacOS'; 10 | } else if (windowsPlatforms.indexOf(platform) !== -1) { 11 | os = 'Windows'; 12 | } 13 | return os; 14 | } 15 | 16 | export { getOS }; -------------------------------------------------------------------------------- /src/components/ui/divider.tsx: -------------------------------------------------------------------------------- 1 | export default function Divider({ className }: { className: string }) { 2 | return ( 3 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/openapi/teams/index.ts: -------------------------------------------------------------------------------- 1 | import { ZodOpenApiPathsObject } from "zod-openapi"; 2 | 3 | import { createTeam } from "./create-team"; 4 | import { getTeamInfo } from "./get-team-info"; 5 | import { getTeams } from "./get-teams"; 6 | import { updateTeam } from "./update-a team"; 7 | 8 | export const teamsPath: ZodOpenApiPathsObject = { 9 | "/teams": { 10 | get: getTeams, 11 | post: createTeam, 12 | }, 13 | "/teams/{team_slug}": { 14 | get: getTeamInfo, 15 | put: updateTeam 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/app/(dashboard)/settings/layout.tsx: -------------------------------------------------------------------------------- 1 | import SettingsLayout from "@/components/layout/settings-layout"; 2 | import { ReactNode } from "react"; 3 | 4 | export default function PersonalSettingsLayout({ 5 | children, 6 | }: { 7 | children: ReactNode; 8 | }) { 9 | const tabs = [ 10 | { 11 | name: "General", 12 | segment: null, 13 | }, 14 | { 15 | name: "API Keys", 16 | segment: "tokens", 17 | }, 18 | ]; 19 | 20 | return {children}; 21 | } 22 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/settings/loading.tsx: -------------------------------------------------------------------------------- 1 | export default function TeamSettingsLoading() { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/[workspace_slug]/settings/layout.tsx: -------------------------------------------------------------------------------- 1 | import SettingsLayout from "@/components/layout/settings-layout"; 2 | import { ReactNode } from "react"; 3 | 4 | export default function PersonalSettingsLayout({ 5 | children, 6 | }: { 7 | children: ReactNode; 8 | }) { 9 | const tabs = [ 10 | { 11 | name: "General", 12 | segment: null, 13 | }, 14 | { 15 | name: "People", 16 | segment: `people`, 17 | }, 18 | ]; 19 | 20 | return {children}; 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/actions.ts: -------------------------------------------------------------------------------- 1 | import { signIn } from "@/lib/auth/auth"; 2 | 3 | export async function authenticate( 4 | prevState: string | undefined, 5 | formData: FormData, 6 | ) { 7 | try { 8 | await signIn("credentials", formData); 9 | } catch (error: any) { 10 | console.log(error); 11 | if (error) { 12 | switch (error.type) { 13 | case "CredentialsSignin": 14 | return "Invalid credentials."; 15 | default: 16 | return "Something went wrong."; 17 | } 18 | } 19 | throw error; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/components/drag-n-drop/column-context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | 3 | import invariant from "tiny-invariant"; 4 | 5 | export type ColumnContextProps = { 6 | columnId: string; 7 | getCardIndex: (id: string) => number; 8 | getNumCards: () => number; 9 | }; 10 | 11 | export const ColumnContext = createContext(null); 12 | 13 | export function useColumnContext(): ColumnContextProps { 14 | const value = useContext(ColumnContext); 15 | invariant(value, "cannot find ColumnContext provider"); 16 | return value; 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/analytics/page-view-tracker.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { usePathname } from "next/navigation"; 4 | import { useEffect } from "react"; 5 | import { useAnalytics } from "./useAnalytics"; 6 | 7 | export const PageViewTracker = () => { 8 | const analytics = useAnalytics(); 9 | const pathname = usePathname(); 10 | 11 | useEffect(() => { 12 | if (analytics.isReady && pathname) { 13 | analytics.page(pathname, { 14 | referrer: document.referrer, 15 | }); 16 | } 17 | }, [pathname, analytics.isReady, analytics]); 18 | 19 | return null; 20 | }; 21 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/settings/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import SettingsLayout from "@/components/layout/settings-layout"; 4 | import { ReactNode } from "react"; 5 | 6 | export default function TeamLayout({ children }: { children: ReactNode }) { 7 | const tabs = [ 8 | { 9 | name: "General", 10 | segment: null, 11 | }, 12 | { 13 | name: "People", 14 | segment: "people", 15 | }, 16 | { 17 | name: "Billing", 18 | segment: "billing", 19 | }, 20 | ]; 21 | return {children}; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/layout/nav-layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface NavbarLayoutProps { 4 | children: React.ReactNode; 5 | } 6 | 7 | /** 8 | * NavbarLayout is a layout component that wraps navbar and content 9 | * @param children 10 | */ 11 | 12 | export const NavbarLayout = ({ children }: NavbarLayoutProps) => { 13 | return ( 14 |
15 |
16 |
{children}
17 |
18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/icons/three-dots.tsx: -------------------------------------------------------------------------------- 1 | export default function ThreeDots({ className }: { className: string }) { 2 | return ( 3 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/ui/icons/check-circle-fill.tsx: -------------------------------------------------------------------------------- 1 | export default function CheckCircleFill({ className }: { className?: string }) { 2 | return ( 3 | 11 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/[workspace_slug]/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import WorkspaceView from "@/components/team/workspace/workspace-view"; 3 | import { useParams, usePathname } from "next/navigation"; 4 | import { ReactNode } from "react"; 5 | 6 | export default function WorkspaceLayout({ children }: { children: ReactNode }) { 7 | const param = useParams() as { team_slug: string; workspace_slug: string }; 8 | const pathname = usePathname(); 9 | return pathname?.includes( 10 | `/${param?.team_slug}/${param?.workspace_slug}/settings`, 11 | ) ? ( 12 | children 13 | ) : ( 14 | {children} 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { WorkspacePlaceholder } from "./page-client"; 2 | 3 | export default function TeamPageLoading() { 4 | return ( 5 |
6 |
7 |
8 |
9 |

10 | My Workspaces 11 |

12 |
13 |
14 |
15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/add-example/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | /** 4 | * Terms and conditions page 5 | */ 6 | export default function TermsAndConditionPage({ 7 | params, 8 | }: { 9 | params: { slug: string }; 10 | }) { 11 | return ( 12 |
13 |
14 |
15 |
16 | {/* */} 17 | {params.slug} 18 |
19 |
20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /scripts/get-api-users.ts: -------------------------------------------------------------------------------- 1 | import "dotenv-flow/config"; 2 | import mongodb, { databaseName } from "@/lib/mongodb"; 3 | 4 | 5 | // npm run script get-api-users 6 | // tsx ./scripts/get-api-users.ts 7 | async function main() { 8 | const client = await mongodb; 9 | const usersCollection = client.db(databaseName).collection("users"); 10 | 11 | console.log("\n-------------------- Users --------------------\n"); 12 | 13 | const users = await usersCollection.find().toArray(); 14 | console.log(users.map((user) => user.email).join(",\n")); 15 | console.log("\n------------------------------------------------\n"); 16 | 17 | client.close(); 18 | process.exit(0); 19 | } 20 | 21 | 22 | main(); 23 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/settings/billing/loading.tsx: -------------------------------------------------------------------------------- 1 | export default function TeamBillingSettingsLoading() { 2 | return ( 3 |
4 |
5 |
6 |

Plan & Usage

7 |

8 | You are currently on the free plan. Current billing cycle: Apr 10 - 9 | May 9. 10 |

11 |
12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/db-schema/collection.schema.ts: -------------------------------------------------------------------------------- 1 | // import Mongoose from "mongoose"; 2 | // const Schema = Mongoose.Schema; 3 | 4 | import { ObjectId } from "mongodb"; 5 | import { MetaSchema } from "./team.schema"; 6 | 7 | export interface CollectionDbSchema { 8 | _id: ObjectId; 9 | object: "item" | "collection"; 10 | workspace: ObjectId; 11 | children: any; 12 | team: ObjectId; 13 | parent: ObjectId | null; 14 | /** 15 | * @deprecated title is deprecated, use name instead 16 | */ 17 | title: string; 18 | name: string; 19 | content: string; 20 | sortIndex: number; 21 | meta: MetaSchema; 22 | createdAt: Date; 23 | createdBy: ObjectId; 24 | updatedBy?: ObjectId; 25 | updatedAt: Date; 26 | } 27 | -------------------------------------------------------------------------------- /scripts/run.ts: -------------------------------------------------------------------------------- 1 | // runner.ts 2 | import { exec } from "child_process"; 3 | 4 | const command: string = process.argv[2]; 5 | 6 | if (!command) { 7 | console.error("Please provide a command name."); 8 | process.exit(1); 9 | } 10 | 11 | const scriptPath = `./scripts/${command}.${command === "send-emails" ? "tsx" : "ts" 12 | }`; 13 | 14 | console.log(`Running script: ${scriptPath}`); 15 | 16 | exec( 17 | `tsx --stack-size=5120000 ${scriptPath}`, 18 | { maxBuffer: 1024 * 5000 }, 19 | (error, stdout, stderr) => { 20 | if (error) { 21 | console.error(`Error executing script: ${error.message}`); 22 | return; 23 | } 24 | console.log(stdout); 25 | console.error(stderr); 26 | }, 27 | ); 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/app/api/teams/[team_slug]/billing/manage/route.ts: -------------------------------------------------------------------------------- 1 | import { withTeam } from "@/lib/auth"; 2 | import { APP_DOMAIN } from "@/lib/constants"; 3 | import { stripe } from "@/lib/stripe"; 4 | import { NextResponse } from "next/server"; 5 | 6 | // POST /api/teams/[team_slug]/billing/manage - create a Stripe billing portal session 7 | export const POST = withTeam(async ({ team }) => { 8 | if (!team.subscriptionId) { 9 | return new Response("No Stripe customer ID", { status: 400 }); 10 | } 11 | const { url } = await stripe.billingPortal.sessions.create({ 12 | customer: team.subscriptionId, 13 | return_url: `${APP_DOMAIN}/${team.meta.slug}/settings/billing`, 14 | }); 15 | return NextResponse.json(url); 16 | }); 17 | -------------------------------------------------------------------------------- /src/components/atom/spinner.tsx: -------------------------------------------------------------------------------- 1 | import cx from "classnames"; 2 | // LoadingSpinner component 3 | export const Spinner = ({ className }: { className?: string }) => { 4 | return ( 5 | 11 | 19 | 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/team/create/create-team.tsx: -------------------------------------------------------------------------------- 1 | import { Logo } from "@/components/atom/logo"; 2 | import Label from "../../atom/label"; 3 | import AddTeam from "./create-team-form"; 4 | 5 | export function CreateTeam() { 6 | return ( 7 |
8 | 9 | 12 |
13 | 14 | 15 | Looking to join an existing team? Ask someone of that team to invite 16 | you. 17 | 18 |
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/functions/fetch-with-timeout.ts: -------------------------------------------------------------------------------- 1 | export function fetchWithTimeout( 2 | input: RequestInfo | URL, 3 | init?: RequestInit | undefined, 4 | timeout: number = 5000, 5 | ) { 6 | return new Promise((resolve, reject) => { 7 | const controller = new AbortController(); 8 | const timeoutId = setTimeout(() => { 9 | controller.abort(); 10 | reject(new Error("Request timed out")); 11 | }, timeout); 12 | fetch(input, { ...init, signal: controller.signal }) 13 | .then((response) => { 14 | clearTimeout(timeoutId); 15 | resolve(response); 16 | }) 17 | .catch((error) => { 18 | clearTimeout(timeoutId); 19 | reject(error); 20 | }); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/auth/utils.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from "crypto"; 2 | import { getServerSession } from "next-auth/next"; 3 | import { NextAuthOptions } from "."; 4 | 5 | 6 | export interface Session { 7 | user: { 8 | email: string; 9 | id: string; 10 | name: string; 11 | image?: string; 12 | }; 13 | } 14 | 15 | export const getSession = async () => { 16 | return getServerSession(NextAuthOptions) as Promise; 17 | }; 18 | 19 | export const hashToken = ( 20 | token: string, 21 | { 22 | noSecret = false, 23 | }: { 24 | noSecret?: boolean; 25 | } = {}, 26 | ) => { 27 | return createHash("sha256") 28 | .update(`${token}${noSecret ? "" : process.env.NEXTAUTH_SECRET}`) 29 | .digest("hex"); 30 | }; 31 | -------------------------------------------------------------------------------- /src/lib/swr/use-users.ts: -------------------------------------------------------------------------------- 1 | import { useParams } from "next/navigation"; 2 | import useSWR from "swr"; 3 | import { fetcher } from "../fetcher"; 4 | import { TeamMemberSchema } from "../zod/schemas"; 5 | import { z } from "zod"; 6 | 7 | 8 | interface Props { 9 | users: z.infer[], 10 | loading: boolean, 11 | error: any 12 | } 13 | export default function useUsers(): Props { 14 | const { team_slug } = useParams() as { 15 | team_slug: string; 16 | }; 17 | 18 | const { data: res, error } = useSWR( 19 | team_slug && `/api/teams/${team_slug}/users`, 20 | fetcher, 21 | ); 22 | 23 | return { 24 | users: res?.users ?? [], 25 | loading: !error && !res?.users, 26 | error, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/openapi/workspace/index.ts: -------------------------------------------------------------------------------- 1 | import { ZodOpenApiPathsObject } from "zod-openapi"; 2 | 3 | import { createWorkspace } from "../workspace/create-workspace"; 4 | import { getWorkspaceInfo } from "../workspace/get-workspace-info"; 5 | import { getWorkspaces } from "../workspace/get-workspaces"; 6 | import { updateWorkspace } from "./update-a-workspace"; 7 | import { deleteWorkspace } from "./delete-workspace"; 8 | 9 | export const workspacePath: ZodOpenApiPathsObject = { 10 | "/teams/{team_slug}/workspaces": { 11 | post: createWorkspace, 12 | get: getWorkspaces, 13 | 14 | 15 | }, 16 | "/teams/{team_slug}/workspaces/{workspace_slug}": { 17 | get: getWorkspaceInfo, 18 | put: updateWorkspace, 19 | delete: deleteWorkspace, 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /src/lib/swr/use-team-invite.ts: -------------------------------------------------------------------------------- 1 | import { useParams } from "next/navigation"; 2 | import useSWR from "swr"; 3 | import { fetcher } from "../fetcher"; 4 | import { InviteTeamMemberSchema } from "../zod/schemas"; 5 | import { z } from "zod"; 6 | interface Props { 7 | users: z.infer[], 8 | loading: boolean, 9 | error: any 10 | } 11 | 12 | export default function useTeamInvite(): Props { 13 | const { team_slug } = useParams() as { 14 | team_slug: string; 15 | }; 16 | 17 | const { data: res, error } = useSWR( 18 | team_slug && `/api/teams/${team_slug}/invites`, 19 | fetcher, 20 | ); 21 | 22 | return { 23 | users: res?.users ?? [], 24 | loading: !error && !res?.users, 25 | error, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/app/api/auth/account-exists/route.ts: -------------------------------------------------------------------------------- 1 | import mongoDb, { databaseName } from "@/lib/mongodb"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | 4 | export async function POST(req: NextRequest) { 5 | const client = await mongoDb; 6 | const { email } = (await req.json()) as { email: string }; 7 | if (!process.env.MONGODB_URI) { 8 | return new Response("Database connection not established", { 9 | status: 500, 10 | }); 11 | } 12 | 13 | const userCollection = client.db(databaseName).collection("users"); 14 | const user = await userCollection.findOne({ 15 | email: email, 16 | }); 17 | 18 | if (user) { 19 | return NextResponse.json({ exists: true }); 20 | } 21 | 22 | return NextResponse.json({ exists: false }); 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/constants/framer-motion.ts: -------------------------------------------------------------------------------- 1 | export const FRAMER_MOTION_LIST_ITEM_VARIANTS = { 2 | hidden: { scale: 0.8, opacity: 0 }, 3 | show: { scale: 1, opacity: 1, transition: { type: "spring" } }, 4 | }; 5 | 6 | export const STAGGER_CHILD_VARIANTS = { 7 | hidden: { opacity: 0, y: 20 }, 8 | show: { opacity: 1, y: 0, transition: { duration: 0.4, type: "spring" } }, 9 | }; 10 | 11 | export const SWIPE_REVEAL_ANIMATION_SETTINGS = { 12 | initial: { height: 0 }, 13 | animate: { height: "auto" }, 14 | exit: { height: 0 }, 15 | transition: { duration: 0.15, ease: "easeOut" }, 16 | }; 17 | 18 | export const FADE_IN_ANIMATION_SETTINGS = { 19 | initial: { opacity: 0 }, 20 | animate: { opacity: 1 }, 21 | exit: { opacity: 0 }, 22 | transition: { duration: 0.2 }, 23 | }; 24 | -------------------------------------------------------------------------------- /src/lib/db-schema/workspace.schema.ts: -------------------------------------------------------------------------------- 1 | import { MetaSchema } from "./team.schema"; 2 | import { ObjectId } from "mongodb"; 3 | import { WorkspaceRole } from "../constants/workspace-role"; 4 | import { AccessLevel, Visibility } from "../types"; 5 | 6 | export interface WorkspaceDbSchema { 7 | _id: string; 8 | team: ObjectId; 9 | name: string; 10 | description: string; 11 | visibility: Visibility; 12 | meta: MetaSchema; 13 | createdAt: Date; 14 | updatedAt: Date; 15 | createdBy: ObjectId; 16 | updatedBy?: ObjectId; 17 | defaultAccess: AccessLevel 18 | } 19 | 20 | 21 | export interface WorkspaceMemberDBSchema { 22 | role: WorkspaceRole, 23 | user: ObjectId, 24 | workspace: ObjectId, 25 | team: ObjectId, 26 | createdAt: Date, 27 | updatedAt: Date, 28 | } -------------------------------------------------------------------------------- /emails/component/footer.tsx: -------------------------------------------------------------------------------- 1 | import { Hr, Tailwind, Text } from "@react-email/components"; 2 | 3 | export default function Footer({ 4 | email, 5 | marketing, 6 | }: { 7 | email: string; 8 | marketing?: boolean; 9 | }) { 10 | if (marketing) { 11 | return null; 12 | } 13 | 14 | return ( 15 | 16 |
17 | 18 | This email was intended for {email}. 19 | If you were not expecting this email, you can ignore this email. If you 20 | are concerned about your account's safety, please reply to this email to 21 | get in touch with us. 22 | 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/components/ui/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./avatar"; 2 | export * from "./background"; 3 | export * from "./badge"; 4 | export * from "./button"; 5 | export * from "./command"; 6 | export * from "./copy-button"; 7 | export * from "./dialog"; 8 | export * from "./divider"; 9 | export * from "./dropdown-menu"; 10 | export * from "./form"; 11 | export * from "./input"; 12 | export * from "./listview"; 13 | export * from "./loading-spinner"; 14 | export * from "./model"; 15 | export * from "./popover"; 16 | export * from "./select"; 17 | export * from "./separator"; 18 | export * from "./sheet"; 19 | export * from "./switch"; 20 | export * from "./team-switcher"; 21 | export * from "./theme-provider"; 22 | export * from "./theme-switcher"; 23 | export * from "./toast"; 24 | export * from "./toaster"; 25 | export * from "./tooltip"; 26 | -------------------------------------------------------------------------------- /src/lib/openapi/teams/get-teams.ts: -------------------------------------------------------------------------------- 1 | import { openApiErrorResponses } from "@/lib/openapi/responses"; 2 | import z from "@/lib/zod"; 3 | import { TeamSchema } from "@/lib/zod/schemas/teams"; 4 | 5 | import { ZodOpenApiOperationObject } from "zod-openapi"; 6 | 7 | export const getTeams: ZodOpenApiOperationObject = { 8 | operationId: "getTeams", 9 | "x-speakeasy-name-override": "list", 10 | summary: "Retrieve a list of team", 11 | description: "Retrieve a list of team for the authenticated user.", 12 | responses: { 13 | "200": { 14 | description: "A list of team", 15 | content: { 16 | "application/json": { 17 | schema: z.array(TeamSchema), 18 | }, 19 | }, 20 | }, 21 | ...openApiErrorResponses, 22 | }, 23 | tags: ["teams"], 24 | security: [{ token: [] }], 25 | }; 26 | -------------------------------------------------------------------------------- /src/lib/fetcher.ts: -------------------------------------------------------------------------------- 1 | export interface SWRError extends Error { 2 | status: number; 3 | message: string; 4 | } 5 | 6 | export async function fetcher( 7 | input: RequestInfo, 8 | init?: RequestInit, 9 | ): Promise { 10 | const res = await fetch(input, init); 11 | const json = await res.json(); 12 | json.status = res.status; 13 | try { 14 | if (!res.ok) { 15 | const error = await res.text(); 16 | const body = JSON.parse(error); 17 | const err = new Error(error) as SWRError; 18 | err.status = res.status; 19 | err.message = body?.message; 20 | console.error(err); 21 | throw err; 22 | } 23 | } catch (error) { 24 | // console.error("error[]", error); 25 | if (json) { 26 | throw json; 27 | } 28 | throw error; 29 | } 30 | 31 | return json; 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/openapi/teams/create-team.ts: -------------------------------------------------------------------------------- 1 | import { openApiErrorResponses } from "@/lib/openapi/responses"; 2 | import { TeamSchema, createTeamSchema } from "@/lib/zod/schemas/teams"; 3 | 4 | export const createTeam = { 5 | operationId: "createTeam", 6 | "x-speakeasy-name-override": "create", 7 | summary: "Create a team", 8 | description: "Create a new team for the authenticated user.", 9 | requestBody: { 10 | content: { 11 | "application/json": { 12 | schema: createTeamSchema, 13 | }, 14 | }, 15 | }, 16 | responses: { 17 | "200": { 18 | description: "The created team", 19 | content: { 20 | "application/json": { 21 | schema: TeamSchema, 22 | }, 23 | }, 24 | }, 25 | ...openApiErrorResponses, 26 | }, 27 | tags: ["teams"], 28 | security: [{ token: [] }], 29 | }; 30 | -------------------------------------------------------------------------------- /src/app/api/teams/[team_slug]/invites/reset/route.ts: -------------------------------------------------------------------------------- 1 | import { withTeam } from "@/lib/auth"; 2 | import mongoDb, { databaseName } from "@/lib/mongodb"; 3 | import { randomId } from "@/lib/utils"; 4 | import { ObjectId } from "mongodb"; 5 | import { NextResponse } from "next/server"; 6 | 7 | // POST /api/teams/[team_slug]/invites/reset – reset invite code for a team 8 | export const POST = withTeam( 9 | async ({ team }) => { 10 | const client = await mongoDb; 11 | const teamsDb = client.db(databaseName).collection("teams"); 12 | const query = { _id: new ObjectId(team._id) }; 13 | 14 | const inviteCode = randomId(24); 15 | await teamsDb.updateOne(query, { 16 | $set: { 17 | inviteCode, 18 | }, 19 | }); 20 | 21 | return NextResponse.json({ inviteCode }, { status: 200 }); 22 | }, 23 | { 24 | requiredRole: ["owner"], 25 | }, 26 | ); 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env.server 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | .turbo 40 | .env.development 41 | docs/ 42 | .cursor/rules/analytics-architecture.mdc 43 | .cursor/rules/analytics-error-handling.mdc 44 | .cursor/rules/analytics-privacy-compliance.mdc 45 | .cursor/rules/analytics-react-integration.mdc 46 | .cursor/rules/analytics-testing.mdc 47 | .cursor/rules/analytics-validation.mdc 48 | -------------------------------------------------------------------------------- /src/components/ui/progress-bar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { motion } from "framer-motion"; 5 | 6 | export function ProgressBar({ 7 | value = 0, 8 | max = 100, 9 | className, 10 | }: { 11 | value?: number; 12 | max?: number; 13 | className?: string; 14 | }) { 15 | return ( 16 |
22 | max ? "bg-red-500" : "bg-blue-500" 30 | } h-full`} 31 | /> 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/api/collection.ts: -------------------------------------------------------------------------------- 1 | import { databaseName } from "@/lib/mongodb"; 2 | import { MongoClient, ObjectId } from "mongodb"; 3 | 4 | /** 5 | * Remove all workspace's collections 6 | */ 7 | export async function removeAllWorkspaceCollection(client: MongoClient, teamId: string, workspaceId: string) { 8 | const workspaceMembersCol = client 9 | .db(databaseName) 10 | .collection("collections"); 11 | return await workspaceMembersCol.deleteMany({ team: new ObjectId(teamId), workspace: new ObjectId(workspaceId) }); 12 | } 13 | 14 | /** 15 | * Remove all collections for team. Operation to be performed when a team is deleted 16 | */ 17 | export async function removeAllTeamCollections(client: MongoClient, teamId: string) { 18 | const collectionsCol = client 19 | .db(databaseName) 20 | .collection("collections"); 21 | return await collectionsCol.deleteMany({ team: new ObjectId(teamId) }); 22 | } -------------------------------------------------------------------------------- /types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { DefaultSession } from "next-auth"; 2 | 3 | declare module "next-auth" { 4 | /** 5 | * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context 6 | */ 7 | interface Session { 8 | user: { 9 | /** The user's postal address. */ 10 | id?: string; 11 | } & DefaultSession["user"]; 12 | } 13 | 14 | /** 15 | * The shape of the user object returned in the OAuth providers' `profile` callback, 16 | * or the second parameter of the `session` callback, when using a database. 17 | */ 18 | interface User {} 19 | /** 20 | * Usually contains information about the provider being used 21 | * and also extends `TokenSet`, which is different tokens returned by OAuth Providers. 22 | */ 23 | interface Account {} 24 | /** The OAuth profile returned from your provider */ 25 | interface Profile {} 26 | } 27 | -------------------------------------------------------------------------------- /src/components/drag-n-drop/data.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from "@/lib/types"; 2 | import { ColumnMap } from "./board-context"; 3 | 4 | export function getBasicData(collections: Collection[]) { 5 | const columns = collections.map((coll, index) => { 6 | return { 7 | title: coll.name, 8 | columnId: coll._id, 9 | items: coll.children, 10 | }; 11 | }); 12 | 13 | const outputData: ColumnMap = {}; 14 | 15 | columns.forEach((item) => { 16 | const list = item.items.sort((a, b) => a.sortIndex - b.sortIndex); 17 | outputData[item.columnId] = { 18 | title: item.title, 19 | columnId: item.columnId, 20 | items: list, 21 | collection: collections.find((coll) => coll._id === item.columnId)!, 22 | }; 23 | }); 24 | 25 | const orderedColumnIds = collections.map((coll) => coll._id); 26 | 27 | return { 28 | columnMap: outputData, 29 | orderedColumnIds, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref, 15 | ) => ( 16 | 27 | ), 28 | ); 29 | Separator.displayName = SeparatorPrimitive.Root.displayName; 30 | 31 | export { Separator }; 32 | -------------------------------------------------------------------------------- /src/lib/openapi/teams/get-team-info.ts: -------------------------------------------------------------------------------- 1 | import { openApiErrorResponses } from "@/lib/openapi/responses"; 2 | import z from "@/lib/zod"; 3 | import { TeamSchema } from "@/lib/zod/schemas/teams"; 4 | import { ZodOpenApiOperationObject } from "zod-openapi"; 5 | 6 | export const getTeamInfo: ZodOpenApiOperationObject = { 7 | operationId: "getTeamInfo", 8 | "x-speakeasy-name-override": "get", 9 | summary: "Retrieve a team", 10 | description: "Retrieve a team for the authenticated user.", 11 | requestParams: { 12 | path: z.object({ 13 | team_slug: z.string().describe("The slug of the team."), 14 | }), 15 | }, 16 | responses: { 17 | "200": { 18 | description: "The retrieved team", 19 | content: { 20 | "application/json": { 21 | schema: TeamSchema, 22 | }, 23 | }, 24 | }, 25 | ...openApiErrorResponses, 26 | }, 27 | tags: ["teams"], 28 | security: [{ token: [] }], 29 | }; 30 | -------------------------------------------------------------------------------- /src/lib/openapi/teams/update-a team.ts: -------------------------------------------------------------------------------- 1 | import { openApiErrorResponses } from "@/lib/openapi/responses"; 2 | import z from "@/lib/zod"; 3 | import { TeamSchema, updateTeamSchema } from "@/lib/zod/schemas/teams"; 4 | 5 | export const updateTeam = { 6 | operationId: "updateTeam", 7 | summary: "update a team", 8 | description: "Update a team.", 9 | requestParams: { 10 | path: z.object({ 11 | team_slug: z.string().describe("The slug of the team."), 12 | }), 13 | }, 14 | requestBody: { 15 | content: { 16 | "application/json": { 17 | schema: updateTeamSchema, 18 | }, 19 | }, 20 | }, 21 | responses: { 22 | "200": { 23 | description: "The updated team", 24 | content: { 25 | "application/json": { 26 | schema: TeamSchema, 27 | }, 28 | }, 29 | }, 30 | ...openApiErrorResponses, 31 | }, 32 | tags: ["teams"], 33 | security: [{ token: [] }], 34 | }; 35 | -------------------------------------------------------------------------------- /src/lib/stripe/index.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe"; 2 | 3 | export const stripe = new Stripe( 4 | process.env.STRIPE_SECRET_KEY_LIVE ?? process.env.STRIPE_SECRET_KEY ?? "", 5 | { 6 | apiVersion: "2024-04-10", 7 | appInfo: { 8 | name: "Orgnise", 9 | version: "0.0.1", 10 | }, 11 | }, 12 | ); 13 | 14 | export async function cancelSubscription(customer?: string) { 15 | if (!customer) return; 16 | 17 | try { 18 | const subscriptionId = await stripe.subscriptions 19 | .list({ 20 | customer, 21 | }) 22 | .then((res) => res.data[0].id); 23 | 24 | return await stripe.subscriptions.update(subscriptionId, { 25 | cancel_at_period_end: true, 26 | cancellation_details: { 27 | comment: "Customer deleted their Orgnise team.", 28 | }, 29 | }); 30 | } catch (error) { 31 | console.log("Error cancelling Stripe subscription", error); 32 | return; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/app/api/teams/[team_slug]/billing/upgrade/route.ts: -------------------------------------------------------------------------------- 1 | import { paddle } from "@/app/api/callback/paddle/route"; 2 | import { withTeam } from "@/lib/auth"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export const POST = withTeam(async ({ req, team }) => { 6 | try { 7 | let { priceId } = await req.json(); 8 | 9 | if (!priceId || !team.subscriptionId) { 10 | return new Response("Missing priceId or subscription ID", { status: 400 }); 11 | } 12 | 13 | const subscription = await paddle.subscriptions.update(team.subscriptionId!, { 14 | items: [{ priceId: priceId, quantity: 1 }], 15 | onPaymentFailure: 'prevent_change', 16 | prorationBillingMode: 'prorated_immediately', 17 | 18 | }); 19 | 20 | return NextResponse.json(subscription, { status: 200 }); 21 | 22 | } 23 | catch (error) { 24 | console.log(error); 25 | return NextResponse.json({ error: error }, { status: 500 }); 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | }, 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /src/components/drag-n-drop/board.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useEffect, type ReactNode } from "react"; 2 | 3 | import { autoScrollWindowForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; 4 | 5 | import { useBoardContext } from "./board-context"; 6 | 7 | type BoardProps = { 8 | children: ReactNode; 9 | }; 10 | 11 | const Board = forwardRef( 12 | ({ children }: BoardProps, ref) => { 13 | const { instanceId } = useBoardContext(); 14 | 15 | useEffect(() => { 16 | return autoScrollWindowForElements({ 17 | canScroll: ({ source }) => source.data.instanceId === instanceId, 18 | }); 19 | }, [instanceId]); 20 | return ( 21 |
25 | {children} 26 |
27 | ); 28 | }, 29 | ); 30 | 31 | Board.displayName = "Board"; 32 | export default Board; 33 | -------------------------------------------------------------------------------- /src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Toast, 5 | ToastClose, 6 | ToastDescription, 7 | ToastProvider, 8 | ToastTitle, 9 | ToastViewport, 10 | } from "@/components/ui/toast"; 11 | import { useToast } from "@/components/ui/use-toast"; 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast(); 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ); 31 | })} 32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/app/(dashboard)/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Nav from "@/components/layout/nav"; 4 | import { NavbarLayout } from "@/components/layout/nav-layout"; 5 | import NavTabs from "@/components/layout/teams-nav-tabs"; 6 | import { Toaster } from "@/components/ui/toaster"; 7 | import { ReactNode, Suspense } from "react"; 8 | import Providers from "./providers"; 9 | 10 | export const dynamic = "force-static"; 11 | 12 | export default function Layout({ children }: { children: ReactNode }) { 13 | return ( 14 | 15 |
16 | 17 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/analytics/session-manager.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useSession } from "next-auth/react"; 4 | import { useEffect, useRef } from "react"; 5 | import { loadIdentity } from "./identity"; 6 | import { useAnalytics } from "./useAnalytics"; 7 | 8 | export const AnalyticsSessionManager = () => { 9 | const { data: session, status } = useSession(); 10 | const { identify } = useAnalytics(); 11 | const identifiedOnce = useRef(false); 12 | 13 | useEffect(() => { 14 | // Prevent multiple identify calls on re-renders 15 | if (status === "loading" || identifiedOnce.current) { 16 | return; 17 | } 18 | 19 | const analyticsUser = loadIdentity(); 20 | 21 | if (session?.user?.id && session.user.id !== analyticsUser.userId) { 22 | const { id, name, email } = session.user; 23 | identify(id, { name, email }); 24 | identifiedOnce.current = true; 25 | } 26 | }, [session, status, identify]); 27 | 28 | return null; // This component does not render anything 29 | }; 30 | -------------------------------------------------------------------------------- /src/lib/openapi/workspace/get-workspaces.ts: -------------------------------------------------------------------------------- 1 | import { openApiErrorResponses } from "@/lib/openapi/responses"; 2 | import z from "@/lib/zod"; 3 | import { WorkspaceSchema } from "@/lib/zod/schemas/workspaces"; 4 | 5 | import { ZodOpenApiOperationObject } from "zod-openapi"; 6 | 7 | export const getWorkspaces: ZodOpenApiOperationObject = { 8 | operationId: "getWorkspaces", 9 | "x-speakeasy-name-override": "list", 10 | summary: "Retrieve a list of workspace", 11 | description: "Retrieve a list of workspace for the authenticated user.", 12 | requestParams: { 13 | path: z.object({ 14 | team_slug: z.string().describe("The slug of the team."), 15 | }), 16 | }, 17 | responses: { 18 | "200": { 19 | description: "A list of workspace", 20 | content: { 21 | "application/json": { 22 | schema: z.array(WorkspaceSchema), 23 | }, 24 | }, 25 | }, 26 | ...openApiErrorResponses, 27 | }, 28 | tags: ["workspaces"], 29 | security: [{ token: [] }], 30 | }; 31 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/[workspace_slug]/[collection_slug]/loading.tsx: -------------------------------------------------------------------------------- 1 | export default function Loading() { 2 | return ( 3 |
4 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/openapi/workspace/delete-workspace.ts: -------------------------------------------------------------------------------- 1 | import { openApiErrorResponses } from "@/lib/openapi/responses"; 2 | import z from "@/lib/zod"; 3 | import { deleteWorkspaceUserSchema } from "@/lib/zod/schemas/workspaces"; 4 | import { ZodOpenApiOperationObject } from "zod-openapi"; 5 | 6 | export const deleteWorkspace: ZodOpenApiOperationObject = { 7 | operationId: "getWorkspaceInfo", 8 | summary: "Delete a workspace", 9 | description: "Delete a workspace of a team.", 10 | requestParams: { 11 | path: z.object({ 12 | team_slug: z.string().describe("The slug of the team."), 13 | workspace_slug: z.string().describe("The slug of the workspace."), 14 | }), 15 | }, 16 | responses: { 17 | "200": { 18 | description: "The deleted workspace", 19 | content: { 20 | "application/json": { 21 | schema: deleteWorkspaceUserSchema, 22 | }, 23 | }, 24 | }, 25 | ...openApiErrorResponses, 26 | }, 27 | tags: ["workspaces"], 28 | security: [{ token: [] }], 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/ui/editor/selectors/math-selectors.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { SigmaIcon } from "lucide-react"; 3 | import { useEditor } from "novel"; 4 | import { Button } from "../../button"; 5 | 6 | export const MathSelector = () => { 7 | const { editor } = useEditor(); 8 | 9 | if (!editor) return null; 10 | 11 | return ( 12 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/ui/loading-spinner.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | export function LoadingSpinner({ className }: { className?: string }) { 4 | return ( 5 |
6 |
14 | {[...Array(12)].map((_, i) => ( 15 |
30 | ))} 31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/components/ui/icons/github.tsx: -------------------------------------------------------------------------------- 1 | export default function Github({ className }: { className?: string }) { 2 | return ( 3 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/openapi/workspace/get-workspace-info.ts: -------------------------------------------------------------------------------- 1 | import { openApiErrorResponses } from "@/lib/openapi/responses"; 2 | import z from "@/lib/zod"; 3 | import { WorkspaceSchema } from "@/lib/zod/schemas/workspaces"; 4 | import { ZodOpenApiOperationObject } from "zod-openapi"; 5 | 6 | export const getWorkspaceInfo: ZodOpenApiOperationObject = { 7 | operationId: "getWorkspaceInfo", 8 | "x-speakeasy-name-override": "get", 9 | summary: "Retrieve a workspace", 10 | description: "Retrieve a workspace for the authenticated user.", 11 | requestParams: { 12 | path: z.object({ 13 | team_slug: z.string().describe("The slug of the team."), 14 | workspace_slug: z.string().describe("The slug of the workspace."), 15 | }), 16 | }, 17 | responses: { 18 | "200": { 19 | description: "The retrieved workspace", 20 | content: { 21 | "application/json": { 22 | schema: WorkspaceSchema, 23 | }, 24 | }, 25 | }, 26 | ...openApiErrorResponses, 27 | }, 28 | tags: ["workspaces"], 29 | security: [{ token: [] }], 30 | }; 31 | -------------------------------------------------------------------------------- /src/lib/openapi/collection/get-collections.ts: -------------------------------------------------------------------------------- 1 | import { openApiErrorResponses } from "@/lib/openapi/responses"; 2 | import z from "@/lib/zod"; 3 | import { CollectionSchema } from "@/lib/zod/schemas"; 4 | 5 | import { ZodOpenApiOperationObject } from "zod-openapi"; 6 | 7 | export const getCollections: ZodOpenApiOperationObject = { 8 | operationId: "getCollections", 9 | "x-speakeasy-name-override": "list", 10 | summary: "Retrieve a list of collections", 11 | description: "Retrieve a list of collections of the workspace.", 12 | requestParams: { 13 | path: z.object({ 14 | team_slug: z.string().describe("The slug of the team."), 15 | workspace_slug: z.string().describe("The slug of the workspace."), 16 | }), 17 | }, 18 | responses: { 19 | "200": { 20 | description: "A list of collections", 21 | content: { 22 | "application/json": { 23 | schema: z.array(CollectionSchema), 24 | }, 25 | }, 26 | }, 27 | ...openApiErrorResponses, 28 | }, 29 | tags: ["collections"], 30 | security: [{ token: [] }], 31 | }; 32 | -------------------------------------------------------------------------------- /src/lib/openapi/workspace/update-a-workspace.ts: -------------------------------------------------------------------------------- 1 | import { openApiErrorResponses } from "@/lib/openapi/responses"; 2 | import z from "@/lib/zod"; 3 | import { WorkspaceSchema, updateWorkspaceSchema } from "@/lib/zod/schemas/workspaces"; 4 | 5 | export const updateWorkspace = { 6 | operationId: "updateWorkspace", 7 | summary: "update a workspace", 8 | description: "Update a workspace.", 9 | requestParams: { 10 | path: z.object({ 11 | team_slug: z.string().describe("The slug of the team."), 12 | workspace_slug: z.string().describe("The slug of the workspace.") 13 | }), 14 | }, 15 | requestBody: { 16 | content: { 17 | "application/json": { 18 | schema: updateWorkspaceSchema, 19 | }, 20 | }, 21 | }, 22 | responses: { 23 | "200": { 24 | description: "The updated workspace", 25 | content: { 26 | "application/json": { 27 | schema: WorkspaceSchema, 28 | }, 29 | }, 30 | }, 31 | ...openApiErrorResponses, 32 | }, 33 | tags: ["workspaces"], 34 | security: [{ token: [] }], 35 | }; 36 | -------------------------------------------------------------------------------- /src/lib/paddle/paddle-client.ts: -------------------------------------------------------------------------------- 1 | import { initializePaddle, Paddle, } from '@paddle/paddle-js'; 2 | import { APP_DOMAIN } from '../constants'; 3 | 4 | 5 | let paddlePromise: Promise; 6 | 7 | export async function getPaddle(slug: string, PADDLE_SECRET_CLIENT_KEY?: string, PADDLE_ENV: string = 'sandbox') { 8 | if (!PADDLE_SECRET_CLIENT_KEY) { 9 | throw new Error("Paddle secret key is missing") 10 | } 11 | if (!paddlePromise) { 12 | paddlePromise = initializePaddle({ 13 | environment: PADDLE_ENV as any,//process.env.PADDLE_ENV === 'production' ? 'production' : 'sandbox', 14 | token: PADDLE_SECRET_CLIENT_KEY ?? "", 15 | checkout: { 16 | settings: { 17 | successUrl: `${APP_DOMAIN}/${slug}/settings/billing?success=true`, 18 | displayMode: 'overlay', 19 | showAddTaxId: true, 20 | theme: 'light', 21 | }, 22 | }, 23 | debug: true, 24 | eventCallback(event) { 25 | // console.log(event); 26 | }, 27 | 28 | }) 29 | } 30 | 31 | return paddlePromise; 32 | }; 33 | -------------------------------------------------------------------------------- /src/app/(auth)/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { Logo } from "@/components/atom/logo"; 2 | import { constructMetadata } from "@/lib/utility/construct-metadata"; 3 | import { SignupForm } from "./form"; 4 | 5 | export const metadata = constructMetadata({ 6 | title: `Create your ${process.env.NEXT_PUBLIC_APP_NAME} account`, 7 | }); 8 | 9 | export default function SignupPage() { 10 | return ( 11 |
12 |
13 |
14 | 15 |

Create your Orgnise account

16 |

17 | Get started for free. No credit card required. 18 |

19 |
20 | 21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/app/api/teams/[team_slug]/logo/route.ts: -------------------------------------------------------------------------------- 1 | import mongoDb, { databaseName } from "@/lib/mongodb"; 2 | import { withTeam } from "@/lib/auth"; 3 | import { storage } from "@/lib/storage"; 4 | import z from "@/lib/zod"; 5 | import { NextResponse } from "next/server"; 6 | import { ObjectId } from "mongodb"; 7 | 8 | const uploadLogoSchema = z.object({ 9 | image: z.string().url(), 10 | }); 11 | 12 | // POST /api/teams/[team_slug]/logo – upload a new team logo 13 | export const POST = withTeam( 14 | async ({ req, team }) => { 15 | const { image } = uploadLogoSchema.parse(await req.json()); 16 | 17 | const { url } = await storage.upload(`logos/${team._id}`, image); 18 | const client = await mongoDb; 19 | const teamsDb = client.db(databaseName).collection("teams"); 20 | const query = { _id: new ObjectId(team._id) }; 21 | await teamsDb.updateOne(query, { $set: { logo: url, updatedAt: new Date() } }); 22 | return NextResponse.json({ 23 | message: "Logo uploaded successfully!", 24 | }, { 25 | status: 200, 26 | }); 27 | }, 28 | { 29 | requiredRole: ["owner"], 30 | }, 31 | ); 32 | -------------------------------------------------------------------------------- /src/app/api/upload/route.ts: -------------------------------------------------------------------------------- 1 | import { storage } from "@/lib/storage"; 2 | import { randomId } from "@/lib/utils"; 3 | import z from "@/lib/zod"; 4 | import { NextResponse } from "next/server"; 5 | 6 | export const runtime = "edge"; 7 | 8 | const uploadImageSchema = z.object({ 9 | image: z.string().url(), 10 | path: z.string().optional(), 11 | filename: z.string().optional(), 12 | }); 13 | 14 | 15 | export async function POST(req: Request) { 16 | 17 | 18 | if (!process.env.STORAGE_ACCESS_KEY_ID) { 19 | return new Response( 20 | "Missing STORAGE_ACCESS_KEY_ID. Don't forget to add that to your .env file.", 21 | { 22 | status: 401, 23 | }, 24 | ); 25 | } 26 | 27 | const { image, path, filename } = uploadImageSchema.parse(await req.json()); 28 | 29 | let uploadFilePath = path ? `/${path}` : ""; 30 | 31 | if (filename) { 32 | uploadFilePath = `${uploadFilePath}/${filename}`; 33 | } else { 34 | uploadFilePath = `${uploadFilePath}/${randomId(6)}`; 35 | } 36 | 37 | const { url } = await storage.upload(uploadFilePath, image); 38 | 39 | return NextResponse.json({ url }); 40 | } -------------------------------------------------------------------------------- /src/lib/analytics/identity.ts: -------------------------------------------------------------------------------- 1 | import { UserTraits } from "./interfaces"; 2 | 3 | const USER_ID_KEY = "analytics_user_id"; 4 | const USER_TRAITS_KEY = "analytics_user_traits"; 5 | 6 | export const saveIdentity = (userId: string, traits?: UserTraits) => { 7 | if (typeof window !== "undefined") { 8 | window.localStorage.setItem(USER_ID_KEY, userId); 9 | if (traits) { 10 | window.localStorage.setItem(USER_TRAITS_KEY, JSON.stringify(traits)); 11 | } 12 | } 13 | }; 14 | 15 | export const loadIdentity = (): { userId: string | null; traits: UserTraits | null } => { 16 | if (typeof window !== "undefined") { 17 | const userId = window.localStorage.getItem(USER_ID_KEY); 18 | const traitsString = window.localStorage.getItem(USER_TRAITS_KEY); 19 | const traits = traitsString ? JSON.parse(traitsString) : null; 20 | return { userId, traits }; 21 | } 22 | return { userId: null, traits: null }; 23 | }; 24 | 25 | export const clearIdentity = () => { 26 | if (typeof window !== "undefined") { 27 | window.localStorage.removeItem(USER_ID_KEY); 28 | window.localStorage.removeItem(USER_TRAITS_KEY); 29 | } 30 | }; -------------------------------------------------------------------------------- /src/app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { Logo } from "@/components/atom/logo"; 2 | import { constructMetadata } from "@/lib/utility/construct-metadata"; 3 | import { LoginForm } from "./form"; 4 | 5 | export const metadata = constructMetadata({ 6 | title: `Sign in to ${process.env.NEXT_PUBLIC_APP_NAME}`, 7 | }); 8 | 9 | export default function LoginPage() { 10 | return ( 11 |
12 |
13 |
14 | 15 |

Sign in to Orgnise

16 |

17 | We are happy to see 18 | you back 19 |

20 |
21 | 22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/auth.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ReactNode } from "react"; 4 | 5 | import LayoutLoader from "@/components/layout/loyout-loader"; 6 | import AcceptInvitationRequest from "@/components/team/invite/accept/accept-invite"; 7 | import NotFoundView from "@/components/team/team-not-found"; 8 | import useTeam from "@/lib/swr/use-team"; 9 | import { Invite } from "@/lib/types/types"; 10 | 11 | export default function TeamAuth({ children }: { children: ReactNode }) { 12 | const { loading, error } = useTeam(); 13 | 14 | if (loading) { 15 | return ; 16 | } else if (error?.status === 409 && error?.invite) { 17 | const { invite } = error as { invite?: Invite }; 18 | // Pending team invite 19 | return ( 20 |
21 | 22 |
23 | ); 24 | } 25 | 26 | if (error) { 27 | return ( 28 |
29 | 30 |
31 | ); 32 | } 33 | 34 | return children; 35 | } 36 | -------------------------------------------------------------------------------- /src/components/layout/settings-nav-link.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import Link from "next/link"; 5 | import { useParams, useSelectedLayoutSegment } from "next/navigation"; 6 | import { ReactNode } from "react"; 7 | 8 | export default function NavLink({ 9 | segment, 10 | children, 11 | }: { 12 | segment: string | null; 13 | children: ReactNode; 14 | }) { 15 | const selectedLayoutSegment = useSelectedLayoutSegment(); 16 | const { team_slug, workspace_slug } = useParams() as { 17 | team_slug?: string; 18 | workspace_slug: string; 19 | }; 20 | 21 | const href = `${team_slug ? `/${team_slug}` : ""}${workspace_slug ? `/${workspace_slug}` : ""}/settings${ 22 | segment ? `/${segment}` : "" 23 | }`; 24 | 25 | return ( 26 | 37 | {children} 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/[workspace_slug]/loading.tsx: -------------------------------------------------------------------------------- 1 | export default function Loading() { 2 | return ( 3 |
4 |
5 |
6 |
7 | 8 | Collections{" "} 9 | are a way to organize and group pages together. They provide a 10 | convenient way to{" "} 11 | categorize{" "} 12 | and manage related items within your workspace. By creating 13 | collections, you can easily organize your work / docs and improve 14 | productivity. 15 | 16 |
17 |
18 |
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/components/molecule/usage-limit-view.tsx: -------------------------------------------------------------------------------- 1 | import { CustomTooltipContent, ToolTipWrapper } from "@/components/ui/"; 2 | import { getNextPlan } from "@/lib/constants"; 3 | import { Plan } from "@/lib/types"; 4 | import { Fold } from "@/lib/utils"; 5 | 6 | interface Props { 7 | exceedingLimit: boolean; 8 | upgradeMessage: string; 9 | plan?: Plan; 10 | children: React.ReactNode; 11 | team_slug: string; 12 | placeholder: React.ReactNode; 13 | } 14 | export function UsageLimitView({ 15 | children, 16 | exceedingLimit, 17 | upgradeMessage, 18 | plan, 19 | team_slug, 20 | placeholder, 21 | }: Props) { 22 | return ( 23 | children} 26 | ifAbsent={() => ( 27 | 34 | } 35 | > 36 | {placeholder} 37 | 38 | )} 39 | /> 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/lib/openapi/workspace/create-workspace.ts: -------------------------------------------------------------------------------- 1 | import { openApiErrorResponses } from "@/lib/openapi/responses"; 2 | import z from "@/lib/zod"; 3 | import { WorkspaceSchema, createWorkspaceSchema } from "@/lib/zod/schemas/workspaces"; 4 | 5 | export const createWorkspace = { 6 | operationId: "createWorkspace", 7 | "x-speakeasy-name-override": "create", 8 | summary: "Create a workspace", 9 | description: "Create a new workspace. Workspaces are collections of projects and tasks. Workspaces can be private or public. Private workspaces are only visible to members of the workspace.", 10 | requestParams: { 11 | path: z.object({ 12 | team_slug: z.string().describe("The slug of the team.") 13 | }), 14 | }, 15 | requestBody: { 16 | content: { 17 | "application/json": { 18 | schema: createWorkspaceSchema, 19 | }, 20 | }, 21 | }, 22 | responses: { 23 | "200": { 24 | description: "The created workspace", 25 | content: { 26 | "application/json": { 27 | schema: WorkspaceSchema, 28 | }, 29 | }, 30 | }, 31 | ...openApiErrorResponses, 32 | }, 33 | tags: ["workspaces"], 34 | security: [{ token: [] }], 35 | }; 36 | -------------------------------------------------------------------------------- /src/lib/openapi/collection/reorder-item.ts: -------------------------------------------------------------------------------- 1 | import { openApiErrorResponses } from "@/lib/openapi/responses"; 2 | import z from "@/lib/zod"; 3 | import { reorderCollectionResponseSchema, ReorderCollectionSchema } from "@/lib/zod/schemas"; 4 | 5 | export const reorderItem = { 6 | operationId: "reorderItem", 7 | summary: "Reorder a collection/page", 8 | description: "Reorder a collection/page within or outside a collection. This will change the order of the collection/page in the collection.", 9 | requestParams: { 10 | path: z.object({ 11 | team_slug: z.string().describe("The slug of the team."), 12 | workspace_slug: z.string().describe("The slug of the workspace."), 13 | }), 14 | }, 15 | requestBody: { 16 | content: { 17 | "application/json": { 18 | schema: ReorderCollectionSchema, 19 | }, 20 | }, 21 | }, 22 | responses: { 23 | "200": { 24 | description: "Reorder collection/page", 25 | content: { 26 | "application/json": { 27 | schema: reorderCollectionResponseSchema, 28 | }, 29 | }, 30 | }, 31 | ...openApiErrorResponses, 32 | }, 33 | tags: ["collections"], 34 | security: [{ token: [] }], 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/team/workspace/empty-workspace-view.tsx: -------------------------------------------------------------------------------- 1 | import { P } from "@/components/atom/typography"; 2 | import { Shapes } from "lucide-react"; 3 | 4 | export default function EmptyWorkspaceView() { 5 | return ( 6 |
7 |
8 |
9 | 13 |

No workspace available

14 |
15 | 16 | Workspaces are where you 17 | organize your work 18 | You can create workspaces for different teams, clients, or even 19 | for yourself. For example, an 20 | engineering 21 | workspace could contains all engineering-related tasks. 22 | 23 |
24 |
25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/layout/teams-nav-tabs.tsx: -------------------------------------------------------------------------------- 1 | import useTeam from "@/lib/swr/use-team"; 2 | import Link from "next/link"; 3 | import { useParams, usePathname } from "next/navigation"; 4 | import Tab from "../atom/tab"; 5 | 6 | export default function NavTabs() { 7 | const pathname = usePathname(); 8 | const { team_slug, workspace_slug } = useParams() as { 9 | team_slug?: string; 10 | workspace_slug?: string; 11 | }; 12 | const { loading, error, activeTeam } = useTeam(); 13 | 14 | const tabs = [ 15 | { name: "Workspaces", href: `/${team_slug}` }, 16 | { name: "Settings", href: `/${team_slug}/settings` }, 17 | ]; 18 | 19 | if ( 20 | !team_slug || 21 | error || 22 | (workspace_slug && pathname !== `/${team_slug}/settings`) 23 | ) { 24 | return null; 25 | } 26 | 27 | return ( 28 |
29 | {loading && !activeTeam ? ( 30 |
31 | ) : ( 32 | tabs.map(({ name, href }) => ( 33 | 34 | {}} /> 35 | 36 | )) 37 | )} 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SwitchPrimitives from "@radix-ui/react-switch" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )) 27 | Switch.displayName = SwitchPrimitives.Root.displayName 28 | 29 | export { Switch } 30 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from "class-variance-authority"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | }, 24 | ); 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ); 34 | } 35 | 36 | export { Badge, badgeVariants }; 37 | -------------------------------------------------------------------------------- /src/lib/openapi/collection/create-collection.ts: -------------------------------------------------------------------------------- 1 | import { openApiErrorResponses } from "@/lib/openapi/responses"; 2 | import z from "@/lib/zod"; 3 | import { CollectionSchema, CreateCollectionSchema } from "@/lib/zod/schemas"; 4 | 5 | export const createCollection = { 6 | operationId: "createCollection", 7 | "x-speakeasy-name-override": "create", 8 | summary: "Create a collection/page", 9 | description: "Create a new collection. Collections group related pages together. A collection can have as many as sub-collection or pages as you want, allowing you to organize your content in a flexible way. ", 10 | requestParams: { 11 | path: z.object({ 12 | team_slug: z.string().describe("The slug of the team."), 13 | workspace_slug: z.string().describe("The slug of the workspace."), 14 | }), 15 | }, 16 | requestBody: { 17 | content: { 18 | "application/json": { 19 | schema: CreateCollectionSchema, 20 | }, 21 | }, 22 | }, 23 | responses: { 24 | "200": { 25 | description: "The created collection/page", 26 | content: { 27 | "application/json": { 28 | schema: CollectionSchema, 29 | }, 30 | }, 31 | }, 32 | ...openApiErrorResponses, 33 | }, 34 | tags: ["collections"], 35 | security: [{ token: [] }], 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/team/team-not-found.tsx: -------------------------------------------------------------------------------- 1 | import { FileX2 } from "lucide-react"; 2 | import Link from "next/link"; 3 | 4 | interface NotFoundViewProps { 5 | item: string; 6 | } 7 | 8 | export default function NotFoundView({ item }: NotFoundViewProps) { 9 | return ( 10 |
11 |
12 |
13 | 14 |
15 |

16 | {item} Not Found 17 |

18 |

19 | Bummer! The {item} you are looking for does not exist. You either 20 | typed in the wrong URL or don't have access to this {item}. 21 |

22 | 26 | Go Back 27 | 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/layout/settings-layout.tsx: -------------------------------------------------------------------------------- 1 | import { MaxWidthWrapper } from "@/components/layout/max-width-wrapper"; 2 | import NavLink from "@/components/layout/settings-nav-link"; 3 | import { ReactNode } from "react"; 4 | 5 | export default function SettingsLayout({ 6 | tabs, 7 | children, 8 | }: { 9 | tabs: { 10 | name: string; 11 | segment: string | null; 12 | }[]; 13 | children: ReactNode; 14 | }) { 15 | return ( 16 |
17 |
18 | 19 |
20 |
21 |

Settings

22 |
23 |
24 |
25 |
26 | 27 |
28 | {tabs.map(({ name, segment }, index) => ( 29 | 30 | {name} 31 | 32 | ))} 33 |
34 |
{children}
35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/hooks/use-media-query.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useMediaQuery() { 4 | const [device, setDevice] = useState<"mobile" | "tablet" | "desktop" | null>( 5 | null, 6 | ); 7 | const [dimensions, setDimensions] = useState<{ 8 | width: number; 9 | height: number; 10 | } | null>(null); 11 | 12 | useEffect(() => { 13 | const checkDevice = () => { 14 | if (window.matchMedia("(max-width: 640px)").matches) { 15 | setDevice("mobile"); 16 | } else if ( 17 | window.matchMedia("(min-width: 641px) and (max-width: 1024px)").matches 18 | ) { 19 | setDevice("tablet"); 20 | } else { 21 | setDevice("desktop"); 22 | } 23 | setDimensions({ width: window.innerWidth, height: window.innerHeight }); 24 | }; 25 | 26 | // Initial detection 27 | checkDevice(); 28 | 29 | // Listener for windows resize 30 | window.addEventListener("resize", checkDevice); 31 | 32 | // Cleanup listener 33 | return () => { 34 | window.removeEventListener("resize", checkDevice); 35 | }; 36 | }, []); 37 | 38 | return { 39 | device, 40 | width: dimensions?.width, 41 | height: dimensions?.height, 42 | isMobile: device === "mobile", 43 | isTablet: device === "tablet", 44 | isDesktop: device === "desktop", 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_APP_NAME="APP_NAME" 2 | NEXT_PUBLIC_APP_DOMAIN="http://localhost:3000" 3 | MONGODB_URI="mongodb://localhost:27017/pulse-db" 4 | DATABASE_NAME="DATABASE_NAME" 5 | 6 | # Linux: `openssl rand -hex 32` or go to https://generate-secret.vercel.app/32 7 | AUTH_SECRET= 8 | 9 | AUTH_GITHUB_ID= 10 | AUTH_GITHUB_SECRET= 11 | 12 | AUTH_GOOGLE_ID= 13 | AUTH_GOOGLE_SECRET= 14 | 15 | AUTH_SLACK_ID= 16 | AUTH_SLACK_SECRET= 17 | 18 | EMAIL_SERVER_USER= 19 | EMAIL_SERVER_PASSWORD= 20 | 21 | # Used for internal monitoring & paging 22 | # You can remove this by removing `ORGNISE_SLACK_HOOK_ALERTS` ,`ORGNISE_SLACK_HOOK_NEW_TEAM` and `ORGNISE_SLACK_HOOK_ERRORS` from the codebase 23 | ORGNISE_SLACK_HOOK_ALERTS= 24 | ORGNISE_SLACK_HOOK_ERRORS= 25 | ORGNISE_SLACK_HOOK_NEW_TEAM= 26 | 27 | # Storage 28 | STORAGE_ACCESS_KEY_ID= 29 | STORAGE_SECRET_ACCESS_KEY= 30 | STORAGE_ENDPOINT= 31 | 32 | 33 | # Stripe for payments 34 | # https://dashboard.stripe.com/apikeys 35 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= 36 | STRIPE_SECRET_KEY= 37 | # Set this environment variable to support webhooks — https://stripe.com/docs/webhooks#verify-events 38 | STRIPE_WEBHOOK_SECRET= 39 | 40 | # Paddle for payments 41 | # https://developer.paddle.com/api-reference/about/authentication#generate-authentication 42 | PADDLE_ENV='sandbox' 43 | PADDLE_SECRET_CLIENT_KEY= 44 | PADDLE_SERVER_SECRET_KEY= 45 | PADDLE_WEBHOOK_SECRET= -------------------------------------------------------------------------------- /scripts/team/update-missing-invite-code.ts: -------------------------------------------------------------------------------- 1 | import mongodb, { databaseName } from "@/lib/mongodb"; 2 | import { TeamDbSchema } from "@/lib/db-schema/team.schema"; 3 | import { randomId } from "@/lib/utils"; 4 | import "dotenv-flow/config"; 5 | 6 | 7 | // npm run script /team/update-missing-invite-code 8 | // npx tsx ./scripts/team/update-missing-invite-code.ts 9 | async function main() { 10 | const client = await mongodb; 11 | const teamCollection = client.db(databaseName).collection("teams"); 12 | 13 | console.log("\n-------------------- Teams --------------------\n"); 14 | 15 | // @ts-ignore 16 | const teams = await teamCollection.find({ inviteCode: { $in: [null, undefined, ''] }, }).toArray(); 17 | 18 | if (teams.length > 0) { 19 | console.log(`❌ ${teams.length} teams don't have invite code \n`); 20 | 21 | if (teams.length > 0) { 22 | for (let index = 0; index < teams.length; index++) { 23 | const team = teams[index]; 24 | await teamCollection.updateOne({ _id: team._id }, { $set: { inviteCode: randomId() } }); 25 | } 26 | } 27 | console.log(`✅ Invite codes added for ${teams.length} teams.`); 28 | } else { 29 | console.log("✅ All teams have invite code"); 30 | } 31 | console.log("------------------------------------------------\n"); 32 | client.close(); 33 | process.exit(0); 34 | } 35 | 36 | 37 | main(); 38 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Popover = PopoverPrimitive.Root; 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger; 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )); 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 30 | 31 | export { Popover, PopoverContent, PopoverTrigger }; 32 | -------------------------------------------------------------------------------- /src/lib/zod/schemas/invite-member.ts: -------------------------------------------------------------------------------- 1 | import { roles } from "@/lib/constants/team-role"; 2 | import z from ".."; 3 | 4 | 5 | export const SendInviteSchema = z.object({ 6 | invites: z.array(z.object({ 7 | email: z.string().email().min(1).describe("Email of the user to send the invite to."), 8 | role: z.enum(roles).describe("Role of the user to invite."), 9 | })) 10 | }).describe("Send invite schema.").openapi({ 11 | description: "Send invite to add them in team.", 12 | example: { 13 | invites: [ 14 | { 15 | email: "john@doe.com", 16 | "role": "member" 17 | } 18 | ] 19 | } 20 | }); 21 | 22 | 23 | 24 | export const InviteTeamMemberSchema = z.object({ 25 | _id: z.string().min(1).describe("Id of the team member."), 26 | email: z.string().email().min(1).describe("Email of the team member."), 27 | role: z.enum(roles).describe("Role of the team member."), 28 | expires: z.date().describe("Date when the invite expires."), 29 | createdAt: z.date().describe("Date when the invite was sent"), 30 | }).describe("Invite team member schema.").openapi({ 31 | description: "Invite team member schema.", 32 | example: { 33 | _id: "1", 34 | email: "john@doe.com", 35 | createdAt: new Date(), 36 | expires: new Date(), 37 | role: "member" 38 | } 39 | 40 | }); 41 | 42 | export const TeamInvitesSchema = z.object({ 43 | users: z.array(InviteTeamMemberSchema), 44 | }); -------------------------------------------------------------------------------- /src/components/atom/tab.tsx: -------------------------------------------------------------------------------- 1 | import cx from "classnames"; 2 | import { motion } from "framer-motion"; 3 | import React from "react"; 4 | interface Props { 5 | tab: string; 6 | selected: Boolean; 7 | disabled?: Boolean; 8 | onClick: React.MouseEventHandler; 9 | className?: string; 10 | } 11 | 12 | function Tab({ tab, selected, onClick, className, disabled = false }: Props) { 13 | return ( 14 |
19 |
30 | {tab} 31 |
32 | {selected ? ( 33 | 40 |
41 | 42 | ) : ( 43 |
44 | )} 45 |
46 | ); 47 | } 48 | export default Tab; 49 | -------------------------------------------------------------------------------- /src/components/ui/copy-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { CheckIcon, CopyIcon, LucideIcon } from "lucide-react"; 5 | import { useState } from "react"; 6 | import { toast } from "sonner"; 7 | 8 | export function CopyButton({ 9 | value, 10 | className, 11 | icon, 12 | }: { 13 | value: string; 14 | className?: string; 15 | icon?: LucideIcon; 16 | }) { 17 | const [copied, setCopied] = useState(false); 18 | const Comp = icon || CopyIcon; 19 | return ( 20 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/components/team/workspace/workspace-settings-dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { P } from "@/components/atom/typography"; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuContent, 5 | DropdownMenuItem, 6 | DropdownMenuLabel, 7 | DropdownMenuSeparator, 8 | DropdownMenuTrigger, 9 | } from "@/components/ui/dropdown-menu"; 10 | import { cn } from "@/lib/utils"; 11 | import clsx from "clsx"; 12 | import { MoreVerticalIcon } from "lucide-react"; 13 | import Link from "next/link"; 14 | 15 | interface Props { 16 | team_slug?: string; 17 | workspace_slug?: string; 18 | className?: string; 19 | } 20 | export function WorkspaceSettingsDropDown({ 21 | team_slug, 22 | workspace_slug, 23 | className, 24 | }: Props) { 25 | return ( 26 | 27 | 30 | 33 | 34 | 35 | Options 36 | 37 | 38 | 39 |

Workspace Settings

40 | 41 |
42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/ui/account/delete-account.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import DeleteAccountModal from "@/components/ui/models/delete-account-modal"; 3 | import { useState } from "react"; 4 | 5 | export function DeleteAccountSection() { 6 | const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false); 7 | 8 | return ( 9 |
10 | 14 |
15 |

Delete Account

16 |

17 | Permanently delete your {process.env.NEXT_PUBLIC_APP_NAME} account, 18 | all of your teams, workspaces and their respective collections. This 19 | action cannot be undone - please proceed with caution. 20 |

21 |
22 |
23 | 24 |
25 |
26 | 32 |
33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import Image from "next/image"; 3 | 4 | export function Avatar({ 5 | user = {}, 6 | className, 7 | }: { 8 | user?: { 9 | name?: string | null | undefined; 10 | email?: string | null | undefined; 11 | image?: string | null | undefined; 12 | }; 13 | className?: string; 14 | }) { 15 | if (!user) { 16 | return ( 17 |
23 | ); 24 | } 25 | 26 | return ( 27 | {`Avatar 40 | ); 41 | } 42 | 43 | export function TokenAvatar({ id }: { id: string }) { 44 | return ( 45 | avatar 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | hostname: "lh3.googleusercontent.com", 7 | }, 8 | { 9 | hostname: "api.dicebear.com", 10 | }, 11 | ], 12 | }, 13 | 14 | async headers() { 15 | return [ 16 | { 17 | source: "/:path*", 18 | headers: [ 19 | { 20 | key: "Referrer-Policy", 21 | value: "no-referrer-when-downgrade", 22 | }, 23 | { 24 | key: "X-DNS-Prefetch-Control", 25 | value: "on", 26 | }, 27 | { 28 | key: "X-Frame-Options", 29 | value: "DENY", 30 | }, 31 | ], 32 | }, 33 | { 34 | source: "/api/waitlist", 35 | headers: [ 36 | { key: "Access-Control-Allow-Credentials", value: "false" }, 37 | { 38 | key: "Access-Control-Allow-Origin", 39 | value: `https://${process.env.NEXT_PUBLIC_APP_DOMAIN}`, 40 | }, 41 | { 42 | key: "Access-Control-Allow-Methods", 43 | value: "GET,DELETE,PATCH,POST,PUT", 44 | }, 45 | { 46 | key: "Access-Control-Allow-Headers", 47 | value: 48 | "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date", 49 | }, 50 | ], 51 | }, 52 | ]; 53 | }, 54 | }; 55 | 56 | module.exports = nextConfig; 57 | -------------------------------------------------------------------------------- /src/components/ui/icons/loading-circle.tsx: -------------------------------------------------------------------------------- 1 | export default function LoadingCircle({ dimensions }: { dimensions?: string }) { 2 | return ( 3 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/drag-n-drop/registery.ts: -------------------------------------------------------------------------------- 1 | import invariant from 'tiny-invariant'; 2 | 3 | import type { CleanupFn } from '@atlaskit/pragmatic-drag-and-drop/types'; 4 | 5 | export type CardEntry = { 6 | element: HTMLElement; 7 | actionMenuTrigger: HTMLElement; 8 | }; 9 | 10 | export type ColumnEntry = { 11 | element: HTMLElement; 12 | }; 13 | 14 | /** 15 | * Registering cards and their action menu trigger element, 16 | * so that we can restore focus to the trigger when a card moves between columns. 17 | */ 18 | export function createRegistry() { 19 | const cards = new Map(); 20 | const columns = new Map(); 21 | 22 | function registerCard({ cardId, entry }: { cardId: string; entry: CardEntry }): CleanupFn { 23 | cards.set(cardId, entry); 24 | return function cleanup() { 25 | cards.delete(cardId); 26 | }; 27 | } 28 | 29 | function registerColumn({ 30 | columnId, 31 | entry, 32 | }: { 33 | columnId: string; 34 | entry: ColumnEntry; 35 | }): CleanupFn { 36 | columns.set(columnId, entry); 37 | return function cleanup() { 38 | cards.delete(columnId); 39 | }; 40 | } 41 | 42 | function getCard(cardId: string): CardEntry { 43 | const entry = cards.get(cardId); 44 | invariant(entry); 45 | return entry; 46 | } 47 | 48 | function getColumn(columnId: string): ColumnEntry { 49 | const entry = columns.get(columnId); 50 | invariant(entry); 51 | return entry; 52 | } 53 | 54 | return { registerCard, registerColumn, getCard, getColumn }; 55 | } 56 | -------------------------------------------------------------------------------- /src/components/team/workspace/collection/content/item/create-item-cta.tsx: -------------------------------------------------------------------------------- 1 | "use Client"; 2 | import { Spinner } from "@/components/atom/spinner"; 3 | import useCollections from "@/lib/swr/use-collections"; 4 | import { Collection } from "@/lib/types/types"; 5 | import clsx from "clsx"; 6 | import { PlusIcon } from "lucide-react"; 7 | import { useParams, useRouter } from "next/navigation"; 8 | import { useState } from "react"; 9 | 10 | interface CreateItemProps { 11 | activeCollection?: Collection; 12 | children: React.ReactNode; 13 | className?: string; 14 | } 15 | export function CreateItemCTA({ 16 | activeCollection, 17 | children, 18 | className, 19 | }: CreateItemProps) { 20 | const { createCollection } = useCollections(); 21 | const param = useParams(); 22 | const router = useRouter(); 23 | const [isLoading, setIsLoading] = useState(false); 24 | // Create a new Collection/Page 25 | async function handleCreateCollection() { 26 | setIsLoading(true); 27 | await createCollection({ object: "item", parent: activeCollection?._id }); 28 | setIsLoading(false); 29 | } 30 | 31 | return ( 32 |
{ 35 | if (!activeCollection || isLoading) { 36 | return; 37 | } 38 | handleCreateCollection(); 39 | }} 40 | > 41 | {isLoading ? ( 42 | 43 | ) : ( 44 | 45 | )} 46 | {children} 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/lib/analytics/retryManager.ts: -------------------------------------------------------------------------------- 1 | export interface RetryConfig { 2 | maxRetries: number; 3 | baseDelay: number; 4 | maxDelay: number; 5 | backoffMultiplier: number; 6 | } 7 | 8 | export class RetryManager { 9 | private retryConfig: RetryConfig; 10 | 11 | constructor(config?: Partial) { 12 | this.retryConfig = { 13 | maxRetries: 3, 14 | baseDelay: 1000, 15 | maxDelay: 30000, 16 | backoffMultiplier: 2, 17 | ...config, 18 | }; 19 | } 20 | 21 | async executeWithRetry( 22 | operation: () => Promise, 23 | context: string 24 | ): Promise { 25 | let lastError: Error | undefined; 26 | 27 | for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) { 28 | try { 29 | return await operation(); 30 | } catch (error) { 31 | lastError = error as Error; 32 | 33 | if (attempt === this.retryConfig.maxRetries) { 34 | console.error( 35 | `[RetryManager] Operation '${context}' failed after ${this.retryConfig.maxRetries} retries`, 36 | lastError 37 | ); 38 | break; // Exit loop 39 | } 40 | 41 | const delay = Math.min( 42 | this.retryConfig.baseDelay * 43 | Math.pow(this.retryConfig.backoffMultiplier, attempt), 44 | this.retryConfig.maxDelay 45 | ); 46 | 47 | await this.delay(delay); 48 | } 49 | } 50 | 51 | throw lastError; 52 | } 53 | 54 | private delay(ms: number): Promise { 55 | return new Promise((resolve) => setTimeout(resolve, ms)); 56 | } 57 | } -------------------------------------------------------------------------------- /public/_static/grid.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/components/drag-n-drop/board-context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | 3 | import invariant from "tiny-invariant"; 4 | 5 | import { Collection } from "@/lib/types"; 6 | import type { CleanupFn } from "@atlaskit/pragmatic-drag-and-drop/types"; 7 | 8 | export type ColumnType = { 9 | title: string; 10 | columnId: string; 11 | collection: Collection; 12 | items: Collection[]; 13 | }; 14 | export type ColumnMap = { [columnId: string]: ColumnType }; 15 | 16 | export type BoardContextValue = { 17 | getColumns: () => ColumnType[]; 18 | 19 | reorderColumn: (args: { startIndex: number; finishIndex: number }) => void; 20 | 21 | reorderCard: (args: { 22 | columnId: string; 23 | startIndex: number; 24 | finishIndex: number; 25 | }) => void; 26 | 27 | moveCard: (args: { 28 | startColumnId: string; 29 | finishColumnId: string; 30 | itemIndexInStartColumn: number; 31 | itemIndexInFinishColumn?: number; 32 | }) => void; 33 | 34 | registerCard: (args: { 35 | cardId: string; 36 | entry: { 37 | element: HTMLElement; 38 | actionMenuTrigger: HTMLElement; 39 | }; 40 | }) => CleanupFn; 41 | 42 | registerColumn: (args: { 43 | columnId: string; 44 | entry: { 45 | element: HTMLElement; 46 | }; 47 | }) => CleanupFn; 48 | 49 | instanceId: symbol; 50 | }; 51 | 52 | export const BoardContext = createContext(null); 53 | 54 | export function useBoardContext(): BoardContextValue { 55 | const value = useContext(BoardContext); 56 | invariant(value, "cannot find BoardContext provider"); 57 | return value; 58 | } 59 | -------------------------------------------------------------------------------- /src/components/molecule/team-permission-view.tsx: -------------------------------------------------------------------------------- 1 | import { TeamPermission, checkPermissions } from "@/lib/constants/team-role"; 2 | import useTeam from "@/lib/swr/use-team"; 3 | import { Fold } from "@/lib/utils"; 4 | import cx from "classnames"; 5 | import React from "react"; 6 | 7 | interface Props { 8 | className?: string; 9 | permission: TeamPermission; 10 | unAuthorized?: React.ReactNode | JSX.Element; 11 | placeholder?: React.ReactNode; 12 | children?: React.ReactNode; 13 | } 14 | 15 | /** 16 | * Permission view component 17 | * @param {TeamPermissionView} permission - Permission to check 18 | * @param {React.ReactNode} children - Children to render if permission is present 19 | * @param {string} className - Class name 20 | * @param {React.ReactNode} placeholder - Placeholder to render while loading 21 | * @param {React.ReactNode} unAuthorized - Children to render if permission is absent 22 | * @example 23 | * 24 | * Create 25 | * 26 | */ 27 | function TeamPermissionView({ 28 | permission, 29 | children, 30 | className, 31 | placeholder, 32 | unAuthorized, 33 | }: Props) { 34 | const { loading, activeTeam } = useTeam(); 35 | if (loading) { 36 | return
{placeholder}
; 37 | } 38 | 39 | return ( 40 | ( 43 |
{children}
44 | )} 45 | ifAbsent={() => unAuthorized} 46 | /> 47 | ); 48 | } 49 | 50 | export default TeamPermissionView; 51 | -------------------------------------------------------------------------------- /scripts/workspace/generate-missing-slug.ts: -------------------------------------------------------------------------------- 1 | import "dotenv-flow/config"; 2 | import mongodb, { databaseName } from "@/lib/mongodb"; 3 | import { generateSlug } from "@/lib/utils"; 4 | import { ObjectId } from "mongodb"; 5 | 6 | 7 | // npm run script /workspace/generate-missing-slug 8 | // npx tsx ./scripts/workspace/generate-missing-slug.ts 9 | async function main() { 10 | const client = await mongodb; 11 | const workspaceCollection = client.db(databaseName).collection("workspaces"); 12 | 13 | console.log("\n-------------------- workspaces --------------------\n"); 14 | 15 | const workspaces = await workspaceCollection.find({ 16 | "meta.slug": { $in: [null, undefined, ''] }, 17 | }).toArray(); 18 | 19 | if (workspaces.length > 0) { 20 | for (const workspace of workspaces) { 21 | const slug = await generateSlug({ 22 | title: workspace?.name ?? "collection ", 23 | didExist: async (val: string) => { 24 | const work = await workspaceCollection.findOne({ 25 | "meta.slug": val, 26 | team: new ObjectId(workspace.team), 27 | }); 28 | return !!work; 29 | }, 30 | suffixLength: 6, 31 | }); 32 | 33 | await workspaceCollection.updateOne( 34 | { _id: workspace._id }, 35 | { $set: { "meta.slug": slug, updatedAt: new Date() } }, 36 | ); 37 | console.log(workspace.name, "=>", slug); 38 | } 39 | } else { 40 | console.log("No workspace found without slug"); 41 | 42 | } 43 | console.log("\n------------------------------------------------\n"); 44 | client.close(); 45 | process.exit(0); 46 | } 47 | 48 | 49 | main(); 50 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/global.css"; 2 | import "@/styles/prosemirror.css"; 3 | 4 | import { ThemeProvider } from "@/components/ui/theme-provider"; 5 | import { AnalyticsProviderComponent } from "@/lib/analytics/provider"; 6 | import { AnalyticsSessionManager } from "@/lib/analytics/session-manager"; 7 | import { AuthSessionProvider } from "@/lib/auth/session-provider"; 8 | import { constructMetadata } from "@/lib/utility/construct-metadata"; 9 | import { cn } from "@/lib/utils"; 10 | import { inter, satoshi } from "@/styles/font"; 11 | import { Toaster } from "sonner"; 12 | 13 | export const metadata = constructMetadata({ 14 | title: "Orgnise", 15 | description: "The internal wiki for modern teams.", 16 | }); 17 | 18 | export default function RootLayout({ 19 | children, 20 | }: { 21 | children: React.ReactNode; 22 | }) { 23 | return ( 24 | 25 | 26 | 33 | 34 | {/* */} 35 | 36 | 37 | 43 | {children} 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /public/_static/slack-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | 21 | 22 | 23 | 24 | 26 | 27 | 28 | 29 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/lib/utility/construct-metadata.ts: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next"; 2 | import { HOME_DOMAIN } from "../constants/constants"; 3 | 4 | export function constructMetadata({ 5 | title = `${process.env.NEXT_PUBLIC_APP_NAME} - Orgnise - Create, Collaborate and orgnise.`, 6 | description = `Streamline your work with our all-in-one knowledge, doc, and project.`, 7 | image = "", 8 | icons = [ 9 | { 10 | rel: "apple-touch-icon", 11 | sizes: "32x32", 12 | url: "/apple-touch-icon.png", 13 | }, 14 | { 15 | rel: "icon", 16 | type: "image/png", 17 | sizes: "32x32", 18 | url: "/favicon-32x32.png", 19 | }, 20 | { 21 | rel: "icon", 22 | type: "image/png", 23 | sizes: "16x16", 24 | url: "/favicon-16x16.png", 25 | }, 26 | ], 27 | noIndex = false, 28 | keywords = "Orgnise, Knowledge, Doc, Project, Streamline, Work, All-in-one,Project management, Documentation, Tool, Software, Online tool", 29 | }: { 30 | title?: string; 31 | description?: string; 32 | image?: string; 33 | icons?: Metadata["icons"]; 34 | noIndex?: boolean; 35 | keywords?: string; 36 | } = {}): Metadata { 37 | return { 38 | title, 39 | description, 40 | keywords, 41 | openGraph: { 42 | title, 43 | description, 44 | images: [ 45 | { 46 | url: image, 47 | }, 48 | ], 49 | }, 50 | twitter: { 51 | card: "summary_large_image", 52 | title, 53 | description, 54 | images: [image], 55 | creator: "@thealphamerc", 56 | }, 57 | icons, 58 | metadataBase: new URL(HOME_DOMAIN), 59 | ...(noIndex && { 60 | robots: { 61 | index: false, 62 | follow: false, 63 | }, 64 | }), 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /src/lib/api/workspace.ts: -------------------------------------------------------------------------------- 1 | import { databaseName } from "@/lib/mongodb"; 2 | import { MongoClient, ObjectId } from "mongodb"; 3 | import { WorkspaceMemberDBSchema } from "../db-schema/workspace.schema"; 4 | 5 | /** 6 | * Remove all workspace members. Operation to be performed when a team is deleted 7 | */ 8 | export async function removeAllTeamWorkspaceMembers(client: MongoClient, teamId: string) { 9 | const workspaceMembersCol = client 10 | .db(databaseName) 11 | .collection("workspace_users"); 12 | return await workspaceMembersCol.deleteMany({ team: new ObjectId(teamId) }); 13 | } 14 | 15 | /** 16 | * Remove all workspace members. Operation to be performed when a workspace is deleted 17 | */ 18 | export async function removeAllWorkspaceMembers(client: MongoClient, teamId: string, workspaceId: string) { 19 | const workspaceMembersCol = client 20 | .db(databaseName) 21 | .collection("workspace_users"); 22 | return await workspaceMembersCol.deleteMany({ team: new ObjectId(teamId), workspace: new ObjectId(workspaceId) }); 23 | } 24 | 25 | /** 26 | * Remove all workspaces for a team 27 | */ 28 | export async function removeAllWorkspaces(client: MongoClient, teamId: string) { 29 | const workspaceCol = client 30 | .db(databaseName) 31 | .collection("workspaces"); 32 | return await workspaceCol.deleteMany({ team: new ObjectId(teamId) }); 33 | } 34 | 35 | /** 36 | * Remove a workspace. Operation to be performed when a workspace is deleted 37 | */ 38 | export async function removeWorkspace(client: MongoClient, teamId: string, workspaceId: string) { 39 | const workspaceCol = client 40 | .db(databaseName) 41 | .collection("workspaces"); 42 | return await workspaceCol.deleteOne({ team: new ObjectId(teamId), _id: new ObjectId(workspaceId) }); 43 | } -------------------------------------------------------------------------------- /src/lib/zod/schemas/team-member.ts: -------------------------------------------------------------------------------- 1 | import { roles } from "@/lib/constants/team-role"; 2 | import z from ".."; 3 | 4 | 5 | export const TeamMemberSchema = z.object({ 6 | _id: z.string().min(1).describe("Id of the team member."), 7 | name: z.string().min(1).describe("Name of the team member."), 8 | email: z.string().email().min(1).describe("Email of the team member."), 9 | image: z.string().optional().describe("Image URL of the team member."), 10 | role: z.enum(roles).describe("Role of the team member."), 11 | }).openapi({ 12 | description: "A team member.", 13 | example: { 14 | _id: "60f4e6f5b0a3d2001f4d8a7d", 15 | name: "John Doe", 16 | email: "john@doe.com", 17 | role: "member", 18 | image: "https://example.com/image.jpg", 19 | } 20 | }) 21 | 22 | export const TeamUsersListSchema = z.object({ 23 | users: z.array(TeamMemberSchema), 24 | }).openapi({ 25 | description: "List of team members.", 26 | }); 27 | 28 | export const updateUserInTeamRoleSchema = z.object({ 29 | userId: z.string().min(1).describe("User Id of the user to update."), 30 | role: z.enum(roles, { 31 | errorMap: () => ({ 32 | message: `Role to update the user to. Must be one of ${roles.join(", ")}.`, 33 | }), 34 | }).describe(`Role to update the user to. Must be one of ${roles.join(", ")}.`), 35 | }).openapi({ 36 | description: "Update a user's role for a specific team.", 37 | example: { 38 | userId: "60f4e6f5b0a3d2001f4d8a7d", 39 | role: "member", 40 | }, 41 | }); 42 | 43 | export const removeUserFromTeamSchema = z.object({ 44 | userId: z.string().min(1).describe("User Id of the user to remove from the team."), 45 | }).openapi({ 46 | description: "Remove a member from a team.", 47 | example: { 48 | userId: "60f4e6f5b0a3d2001f4d8a7d", 49 | }, 50 | }); -------------------------------------------------------------------------------- /src/components/team/invite/accept/accept-invite.tsx: -------------------------------------------------------------------------------- 1 | import { Logo } from "@/components/atom/logo"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Invite } from "@/lib/types/types"; 4 | import { useState } from "react"; 5 | import AcceptInviteModal from "./accept-invite-modal"; 6 | 7 | export default function AcceptInvitationRequest({ 8 | invite, 9 | }: { 10 | invite?: Invite; 11 | }) { 12 | const [showAcceptInviteModal, setShowAcceptInviteModal] = useState(true); 13 | return ( 14 |
15 |
16 | 17 |

18 | Accept Team Invite 19 |

20 |

21 | You have been invited to join 22 | 23 | {invite?.team?.name} 24 | 25 | team. Please accept the invitation to continue. 26 |

27 |
28 | 31 | 37 |
38 |
39 | 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /scripts/team/remove-non-attached-team-members.ts: -------------------------------------------------------------------------------- 1 | import { TeamDbSchema } from "@/lib/db-schema/team.schema"; 2 | import mongodb, { databaseName } from "@/lib/mongodb"; 3 | import "dotenv-flow/config"; 4 | 5 | 6 | // npm run script /team/remove-non-attached-team-members 7 | // npx tsx ./scripts/team/remove-non-attached-team-members.ts 8 | 9 | // Script to remove members from team users collection and are not linked to any team 10 | async function main() { 11 | const client = await mongodb; 12 | const teamUsersColl = client.db(databaseName).collection("team-users"); 13 | 14 | console.log("\n-------------------- Teams --------------------\n"); 15 | 16 | const teamsUsers = await teamUsersColl.aggregate( 17 | [ 18 | { 19 | $lookup: { 20 | from: "teams", 21 | localField: "team", 22 | foreignField: "_id", 23 | as: "team", 24 | }, 25 | }, 26 | { 27 | $match: { 28 | team: { $size: 0 }, 29 | // team: { $exists: [] }, 30 | }, 31 | }, 32 | { 33 | $project: { 34 | team: 1, 35 | teamId: 1, 36 | }, 37 | }, 38 | 39 | ]).toArray(); 40 | 41 | if (teamsUsers.length > 0) { 42 | console.log(`❌ ${teamsUsers.length} Members aren't linked to teams\n`); 43 | 44 | for (let index = 0; index < teamsUsers.length; index++) { 45 | const team = teamsUsers[index]; 46 | await teamUsersColl.deleteOne({ team: team.team }); 47 | console.log(`Member removed from team ${team.team}`); 48 | } 49 | } else { 50 | console.log("✅ All members are linked to at-least one team"); 51 | } 52 | console.log("------------------------------------------------\n"); 53 | client.close(); 54 | process.exit(0); 55 | } 56 | 57 | 58 | main(); 59 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/settings/people/loading.tsx: -------------------------------------------------------------------------------- 1 | export default function TeamPeopleSettingsLoading() { 2 | return ( 3 |
4 |
5 |
6 |

People

7 |

8 | Teammates that have access to this team. 9 |

10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | {Array.from({ length: 5 }).map((_, i) => ( 21 | 22 | ))} 23 |
24 |
25 | ); 26 | } 27 | 28 | export const UserPlaceholder = () => ( 29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | ); 40 | -------------------------------------------------------------------------------- /src/lib/functions/log.ts: -------------------------------------------------------------------------------- 1 | // const logTypeToEnv = { 2 | // alerts: process.env.ORGNISE_SLACK_HOOK_ALERTS, 3 | // tada: process.env.ORGNISE_SLACK_HOOK_NEW_TEAM, 4 | // waitlist: process.env.ORGNISE_SLACK_HOOK_NEW_TEAM, 5 | // errors: process.env.ORGNISE_SLACK_HOOK_ERRORS, 6 | // }; 7 | 8 | export const log = async ({ 9 | message, 10 | type, 11 | mention = false, 12 | }: { 13 | message: string; 14 | type?: "alerts" | 'tada' | "errors" | "waitlist" | 'success' 15 | mention?: boolean; 16 | }) => { 17 | if ( 18 | process.env.NODE_ENV === "development" || 19 | !process.env.ORGNISE_SLACK_HOOK_ALERTS || 20 | !process.env.ORGNISE_SLACK_HOOK_ERRORS 21 | ) { 22 | console.log(message); 23 | } 24 | 25 | function switchType(type: string | undefined) { 26 | switch (type) { 27 | case "alerts": 28 | return "🚨 "; 29 | case "errors": 30 | return "❌ "; 31 | case "tada": 32 | return "🎉 "; 33 | case "success": 34 | return "✅ "; 35 | case "waitlist": 36 | return "📝 "; 37 | default: 38 | return ""; 39 | } 40 | } 41 | /* Log a message to the console */ 42 | const HOOK = process.env.ORGNISE_SLACK_HOOK_ALERTS; 43 | if (!HOOK) return; 44 | try { 45 | return await fetch(HOOK, { 46 | method: "POST", 47 | headers: { 48 | "Content-Type": "application/json", 49 | }, 50 | body: JSON.stringify({ 51 | blocks: [ 52 | { 53 | type: "section", 54 | text: { 55 | type: "mrkdwn", 56 | // prettier-ignore 57 | text: `${switchType(type)}${message}`, 58 | }, 59 | }, 60 | ], 61 | }), 62 | }); 63 | } catch (e) { 64 | console.log(`Failed to log to Orgnise Slack. Error: ${e}`); 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /src/lib/db-schema/team.schema.ts: -------------------------------------------------------------------------------- 1 | // import Mongoose from "mongoose"; 2 | import { TeamRole } from "@/lib/constants/team-role"; 3 | import { ObjectId } from "mongodb"; 4 | import { Plan } from "../types/types"; 5 | import { LimitSchema } from "../zod/schemas"; 6 | import z from "../zod"; 7 | import { CollectionMode, Interval, SubscriptionManagement, SubscriptionScheduledChangeNotification, SubscriptionStatus, SubscriptionTimePeriodNotification } from "@paddle/paddle-node-sdk"; 8 | 9 | export interface TeamDbSchema { 10 | _id: ObjectId; 11 | name: string; 12 | description?: string; 13 | createdBy: ObjectId; 14 | plan: Plan; 15 | subscriptionId?: string; 16 | meta: MetaSchema; 17 | createdAt: Date; 18 | billingCycleStart: number; 19 | inviteCode: string; 20 | logo: string; 21 | updatedAt: Date; 22 | subscription: Subscription; 23 | limit: z.infer; 24 | } 25 | 26 | export interface MetaSchema { 27 | title: string; 28 | description: string; 29 | slug: string; 30 | } 31 | 32 | export interface TeamMemberDbSchema { 33 | _id?: ObjectId; 34 | role: TeamRole; 35 | user: ObjectId; 36 | team: ObjectId; 37 | createdAt: Date; 38 | updatedAt: Date; 39 | } 40 | export interface TeamInviteDbSchema extends Omit { 41 | expires: Date 42 | } 43 | 44 | 45 | export interface Subscription { 46 | id: string; 47 | priceId: string; 48 | status: SubscriptionStatus; 49 | productId: string; 50 | canceledAt?: Date | null; 51 | scheduledChange?: SubscriptionScheduledChangeNotification | null; 52 | nextBilledAt?: Date | null; 53 | pausedAt?: Date | null; 54 | interval?: Interval; 55 | SubscriptionManagementUrls?: SubscriptionManagement | null; 56 | collectionMode?: CollectionMode; 57 | currentBillingPeriod: SubscriptionTimePeriodNotification | null; 58 | } 59 | 60 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/[workspace_slug]/settings/people/loading.tsx: -------------------------------------------------------------------------------- 1 | export default function TeamPeopleSettingsLoading() { 2 | return ( 3 |
4 |
5 |
6 |

People

7 |

8 | Teammates that have access to this workspace. 9 |

10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | {Array.from({ length: 5 }).map((_, i) => ( 21 | 22 | ))} 23 |
24 |
25 | ); 26 | } 27 | 28 | export const UserPlaceholder = () => ( 29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | ); 40 | -------------------------------------------------------------------------------- /src/lib/swr/use-workspace-users.ts: -------------------------------------------------------------------------------- 1 | import { useParams } from "next/navigation"; 2 | import useSWR, { mutate } from "swr"; 3 | import { fetcher } from "../fetcher"; 4 | import { WorkspaceMemberProps } from "../types/types"; 5 | import { displayToast } from "./use-collections"; 6 | 7 | 8 | export interface AddWorkspaceMemberProp { 9 | email: string; 10 | role: string; 11 | } 12 | 13 | interface Props { 14 | users: WorkspaceMemberProps[], 15 | loading: boolean, 16 | error: any, 17 | addMembers: (members: AddWorkspaceMemberProp[]) => Promise 18 | } 19 | export default function useWorkspaceUsers(): Props { 20 | const { team_slug, workspace_slug } = useParams() as { 21 | team_slug: string; 22 | workspace_slug: string; 23 | }; 24 | 25 | const { data: res, error } = useSWR( 26 | team_slug && `/api/teams/${team_slug}/${workspace_slug}/users`, 27 | fetcher, 28 | { 29 | dedupingInterval: 120000, 30 | }); 31 | 32 | 33 | async function addMembers(members: AddWorkspaceMemberProp[]) { 34 | try { 35 | fetcher(`/api/teams/${team_slug}/${workspace_slug}/users`, { 36 | method: "POST", 37 | body: JSON.stringify(members), 38 | }).then((response) => { 39 | mutate(`/api/teams/${team_slug}/${workspace_slug}/users`); 40 | displayToast({ 41 | title: "Members added", 42 | description: "Members have been added", 43 | }); 44 | }); 45 | } catch (error: any) { 46 | console.error("error", error); 47 | displayToast({ 48 | title: "Error", 49 | description: error?.message ?? "Failed to delete team", 50 | variant: "error", 51 | }); 52 | // throw error; 53 | } 54 | } 55 | 56 | return { 57 | users: res?.users ?? [], 58 | loading: !error && !res?.users, 59 | error, 60 | addMembers 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /src/app/api/teams/[team_slug]/billing/cancel-subscription/route.ts: -------------------------------------------------------------------------------- 1 | import { paddle } from "@/app/api/callback/paddle/route"; 2 | import { withTeam } from "@/lib/auth"; 3 | import { TeamDbSchema } from "@/lib/db-schema"; 4 | import { collections } from "@/lib/mongodb"; 5 | import { ObjectId } from "mongodb"; 6 | import { NextResponse } from "next/server"; 7 | 8 | // POST /api/teams/[team_slug]/billing/cancel-subscription - cancel paddle subscription 9 | export const POST = withTeam(async ({ team, client }) => { 10 | if (!team.subscriptionId) { 11 | return new Response("No paddle subscription ID available", { status: 400 }); 12 | } 13 | try { 14 | const subscriptionId = team.subscriptionId; 15 | const res = await paddle.subscriptions.cancel(subscriptionId, { effectiveFrom: 'next_billing_period' }); 16 | 17 | const teams = collections(client, "teams"); 18 | const result = await teams.updateOne({ _id: new ObjectId(team._id) }, { 19 | $set: { 20 | subscription: { 21 | priceId: res.items[0].price!.id, 22 | id: subscriptionId, 23 | status: res.status, 24 | productId: res.items[0].price!.productId, 25 | nextBilledAt: res.nextBilledAt ? new Date(res.nextBilledAt!) : undefined, 26 | pausedAt: res.pausedAt ? new Date(res.pausedAt) : undefined, 27 | SubscriptionManagementUrls: res.managementUrls, 28 | collectionMode: res.collectionMode, 29 | scheduledChange: res.scheduledChange, 30 | interval: res.items[0].price!.billingCycle!.interval, 31 | currentBillingPeriod: res.currentBillingPeriod 32 | }, 33 | updatedAt: new Date() 34 | } 35 | }); 36 | console.log('result', result); 37 | return NextResponse.json(res); 38 | } catch (error: any) { 39 | return NextResponse.json(error, { status: 400 }); 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/[workspace_slug]/[collection_slug]/[item_slug]/loading.tsx: -------------------------------------------------------------------------------- 1 | export default function Loading() { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | 11 |
12 |
13 |
14 |
15 | 16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | 25 |
26 |
27 | 28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /scripts/team/make-team-creator-owner-of-team.ts: -------------------------------------------------------------------------------- 1 | import { TeamDbSchema, TeamMemberDbSchema } from "@/lib/db-schema"; 2 | import mongodb, { databaseName } from "@/lib/mongodb"; 3 | import "dotenv-flow/config"; 4 | import { ObjectId } from "mongodb"; 5 | 6 | 7 | // npm run script team/make-team-creator-owner-of-team 8 | // npx tsx ./scripts/team/make-team-creator-owner-of-team.ts 9 | 10 | // Script to assign default editor to workspaces 11 | async function main() { 12 | const client = await mongodb; 13 | const teamCollection = client.db(databaseName).collection("teams"); 14 | const usersCollection = client.db(databaseName).collection("team-users"); 15 | 16 | 17 | console.log("\n-------------------- teams --------------------\n"); 18 | 19 | 20 | 21 | // @ts-ignore 22 | const teams = await teamCollection.find().toArray(); 23 | 24 | 25 | console.log(`\n Total ${teams.length} teams available without any owner`); 26 | if (teams.length > 0) { 27 | 28 | let insertResult = 0; 29 | for (let i = 0; i < teams.length; i++) { 30 | console.log(`\n 👉 Assigning team owner to workspace ${i + 1}`); 31 | const team = teams[i]; 32 | const teamUser = await usersCollection.insertOne({ 33 | team: team._id, 34 | role: "owner", 35 | user: new ObjectId(team.createdBy), 36 | createdAt: new Date(), 37 | updatedAt: new Date(), 38 | }); 39 | insertResult++; 40 | } 41 | if (insertResult > 0) { 42 | console.log(`✅ ${insertResult} Team owners are assigned to workspaces successfully.`); 43 | } 44 | else { 45 | console.log(`All team owners are already assigned to workspaces. ${insertResult}`); 46 | } 47 | } else { 48 | console.log("No workspaces found."); 49 | } 50 | console.log("------------------------------------------------\n"); 51 | client.close(); 52 | process.exit(0); 53 | } 54 | 55 | 56 | main(); 57 | -------------------------------------------------------------------------------- /src/components/molecule/workspace-permission-view.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | WorkspacePermission, 3 | checkWorkspacePermissions, 4 | } from "@/lib/constants/workspace-role"; 5 | import useTeam from "@/lib/swr/use-team"; 6 | import useWorkspaces from "@/lib/swr/use-workspaces"; 7 | import { Fold } from "@/lib/utils"; 8 | import cx from "classnames"; 9 | import React from "react"; 10 | 11 | export interface Props { 12 | className?: string; 13 | additionalCheck?: boolean; 14 | permission: WorkspacePermission; 15 | unAuthorized?: React.ReactNode | JSX.Element; 16 | placeholder?: React.ReactNode; 17 | children?: React.ReactNode; 18 | } 19 | 20 | /** 21 | * Workspace permission view component 22 | * @param {WorkspacePermissionView} permission - Permission to check 23 | * @param {React.ReactNode} children - Children to render if permission is present 24 | * @param {string} className - Class name 25 | * @param {React.ReactNode} placeholder - Placeholder to render while loading 26 | * @param {React.ReactNode} unAuthorized - Children to render if permission is absent 27 | * @example 28 | * 29 | * Create 30 | * 31 | */ 32 | export function WorkspacePermissionView({ 33 | permission, 34 | children, 35 | className, 36 | placeholder, 37 | unAuthorized, 38 | additionalCheck = true, 39 | }: Props) { 40 | const { activeWorkspace, loading, error } = useWorkspaces(); 41 | const { activeTeam } = useTeam(); 42 | if (loading) { 43 | return
{placeholder}
; 44 | } 45 | 46 | return ( 47 | ( 54 |
{children}
55 | )} 56 | ifAbsent={() => unAuthorized} 57 | /> 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | 3 | import { getToken } from "next-auth/jwt"; 4 | import { parse } from "./lib/utils"; 5 | import { API_HOSTNAMES } from "./lib/constants/constants"; 6 | import ApiMiddleware from "./lib/middleware/api"; 7 | 8 | export const config = { 9 | matcher: [ 10 | /* 11 | * Match all paths except for: 12 | * 1. /api/ routes 13 | * 2. /_next/ (Next.js internals) 14 | * 3. /_proxy/ (special page for OG tags proxying) 15 | * 4. /_static (inside /public) 16 | * 5. /_vercel (Vercel internals) 17 | * 6. Static files (e.g. /favicon.ico, /sitemap.xml, /robots.txt, etc.) 18 | */ 19 | "/((?!api/|_next/|_proxy/|_static|_vercel|[\\w-]+\\.\\w+).*)", 20 | ], 21 | }; 22 | 23 | export const middleware = async (req: NextRequest) => { 24 | const { domain, path, fullPath } = parse(req); 25 | 26 | // for API 27 | if (API_HOSTNAMES.has(domain)) { 28 | return ApiMiddleware(req); 29 | } 30 | 31 | 32 | const session = await getToken({ 33 | req: req, 34 | secret: process.env.AUTH_SECRET, 35 | cookieName: 36 | process.env.NODE_ENV === "production" 37 | ? "__Secure-next-auth.session-token" 38 | : "next-auth.session-token", 39 | }); 40 | 41 | const loggedIn = session?.user ? true : false; 42 | 43 | 44 | 45 | // Redirect to login if not logged in 46 | if ( 47 | !["/login", "/signup", "/credential"].includes(path) && 48 | !loggedIn 49 | ) { 50 | return NextResponse.redirect( 51 | new URL( 52 | `/login${path === "/" ? "" : `?next=${encodeURIComponent(fullPath)}`}`, 53 | req.url, 54 | ), 55 | ); 56 | } 57 | 58 | // Redirect to home if logged in and trying to access login or signup 59 | else if ( 60 | loggedIn && 61 | (path === "/login" || path === "/signup" || path === "/credential") 62 | ) { 63 | return NextResponse.redirect(new URL("/", req.url)); 64 | } else { 65 | return NextResponse.next(); 66 | } 67 | }; 68 | -------------------------------------------------------------------------------- /src/lib/hooks/use-usage.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { z } from "zod"; 3 | import { FREE_TEAMS_LIMIT } from "../constants"; 4 | import useCollections from "../swr/use-collections"; 5 | import useTeam from "../swr/use-team"; 6 | import useTeams from "../swr/use-teams"; 7 | import useUsers from "../swr/use-users"; 8 | import useWorkspaces from "../swr/use-workspaces"; 9 | import { Team } from "../types"; 10 | import { flattenCollectionTree } from "../utility/collection-tree-structure"; 11 | import { LimitSchema } from "../zod/schemas"; 12 | 13 | interface IUsage { 14 | exceedingFreeTeam: boolean; 15 | exceedingWorkspaceLimit: boolean; 16 | exceedingMembersLimit: boolean; 17 | exceedingPageLimit: boolean; 18 | usage: z.infer 19 | limit: z.infer 20 | } 21 | export default function useUsage(): IUsage { 22 | const { teams, loading: teamsLoading } = useTeams(); 23 | const { limit, usage, loading: teamLoading, membersCount } = useTeam(); 24 | const { workspaces, loading: workspacesLoading } = useWorkspaces(); 25 | const { collections } = useCollections(); 26 | const { users } = useUsers(); 27 | 28 | const freeTeams = teams?.filter((team: Team) => team.role === "owner" && team.plan === "free"); 29 | const workspaceLimitInTeam = limit?.workspaces; 30 | const flatCollections = useMemo(() => flattenCollectionTree(collections).filter((collection) => collection?.object === "item"), [collections]); 31 | const pagesCount = flatCollections?.length ?? 0; 32 | 33 | 34 | return { 35 | usage: { 36 | pages: usage?.pages, 37 | users: membersCount ?? usage?.users, 38 | workspaces: workspaces?.length ?? usage?.workspaces 39 | }, 40 | limit, 41 | exceedingFreeTeam: freeTeams?.length > FREE_TEAMS_LIMIT, 42 | exceedingWorkspaceLimit: (workspaces?.length ?? usage?.workspaces) >= workspaceLimitInTeam, 43 | exceedingMembersLimit: (users?.length ?? usage?.users) >= limit?.users, 44 | exceedingPageLimit: (usage?.pages - pagesCount) >= limit?.pages - pagesCount, 45 | }; 46 | } -------------------------------------------------------------------------------- /src/lib/openapi/responses.ts: -------------------------------------------------------------------------------- 1 | import { errorSchemaFactory } from "@/lib/api/errors"; 2 | import { ZodOpenApiComponentsObject } from "zod-openapi"; 3 | 4 | export const openApiErrorResponses: ZodOpenApiComponentsObject["responses"] = { 5 | "400": errorSchemaFactory( 6 | "bad_request", 7 | "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", 8 | ), 9 | 10 | "401": errorSchemaFactory( 11 | "unauthorized", 12 | `Although the HTTP standard specifies "unauthorized", semantically this response means "unauthenticated". That is, the client must authenticate itself to get the requested response.`, 13 | ), 14 | 15 | "403": errorSchemaFactory( 16 | "forbidden", 17 | "The client does not have access rights to the content; that is, it is unauthorized, so the server is refusing to give the requested resource. Unlike 401 Unauthorized, the client's identity is known to the server.", 18 | ), 19 | 20 | "404": errorSchemaFactory( 21 | "not_found", 22 | "The server cannot find the requested resource.", 23 | ), 24 | 25 | "409": errorSchemaFactory( 26 | "conflict", 27 | "This response is sent when a request conflicts with the current state of the server.", 28 | ), 29 | 30 | "410": errorSchemaFactory( 31 | "invite_expired", 32 | "This response is sent when the requested content has been permanently deleted from server, with no forwarding address.", 33 | ), 34 | 35 | "422": errorSchemaFactory( 36 | "unprocessable_entity", 37 | "The request was well-formed but was unable to be followed due to semantic errors.", 38 | ), 39 | 40 | "429": errorSchemaFactory( 41 | "rate_limit_exceeded", 42 | `The user has sent too many requests in a given amount of time ("rate limiting")`, 43 | ), 44 | 45 | "500": errorSchemaFactory( 46 | "internal_server_error", 47 | "The server has encountered a situation it does not know how to handle.", 48 | ), 49 | }; 50 | -------------------------------------------------------------------------------- /src/components/team/create/create-team-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Spinner } from "@/components/atom/spinner"; 3 | import { TextField } from "@/components/molecule/text-field"; 4 | import { Button } from "@/components/ui/button"; 5 | import { useAnalytics } from "@/lib/analytics/useAnalytics"; 6 | import useTeams from "@/lib/swr/use-teams"; 7 | import { Team } from "@/lib/types/types"; 8 | import { useState } from "react"; 9 | 10 | type Status = "idle" | "loading" | "error" | "success"; 11 | 12 | const AddTeam = () => { 13 | const { createTeamAsync } = useTeams(); 14 | const analytics = useAnalytics(); 15 | 16 | const [status, setStatus] = useState("idle"); 17 | 18 | const handleCreateTeam = async (e: any) => { 19 | e.preventDefault(); 20 | const name = e.target.name.value; 21 | const description = e.target.description.value; 22 | setStatus("loading"); 23 | 24 | analytics.track("button_click", { 25 | button_id: "create_team", 26 | location: "create_team_form", 27 | }); 28 | 29 | const payload = { 30 | name: name, 31 | description: description, 32 | } as Team; 33 | createTeamAsync(payload).finally(() => { 34 | setStatus("success"); 35 | }); 36 | }; 37 | 38 | return ( 39 |
43 |
44 | 52 | 60 |
61 | 62 | 65 |
66 | ); 67 | }; 68 | 69 | export default AddTeam; 70 | -------------------------------------------------------------------------------- /scripts/db/create-indexes.ts: -------------------------------------------------------------------------------- 1 | import "dotenv-flow/config"; 2 | import mongoDb from "@/lib/mongodb"; 3 | import { createAllIndexes, getIndexStats } from "@/lib/db/indexes"; 4 | 5 | /** 6 | * Database Index Creation Script 7 | * 8 | * Run this script to create all optimized indexes for the MongoDB database. 9 | * Usage: npx tsx ./scripts/db/create-indexes.ts 10 | */ 11 | 12 | async function main() { 13 | console.log("🚀 Starting database index creation...\n"); 14 | 15 | try { 16 | const client = await mongoDb; 17 | 18 | // Create all indexes 19 | await createAllIndexes(client); 20 | 21 | console.log("\n📊 Getting index statistics..."); 22 | const stats = await getIndexStats(client); 23 | 24 | // Display summary 25 | console.log("\n📈 Index Creation Summary:"); 26 | console.log("========================"); 27 | 28 | for (const collectionStats of stats) { 29 | console.log(`\n📁 Collection: ${collectionStats.collection}`); 30 | console.log(` Indexes: ${collectionStats.indexes.length}`); 31 | 32 | for (const index of collectionStats.indexes) { 33 | if (index.name !== "_id_") { // Skip default _id index 34 | console.log(` - ${index.name}`); 35 | } 36 | } 37 | } 38 | 39 | console.log("\n✅ Database optimization completed successfully!"); 40 | console.log(" Performance improvements should be visible immediately."); 41 | 42 | } catch (error) { 43 | console.error("❌ Error during index creation:", error); 44 | process.exit(1); 45 | } finally { 46 | console.log("\n🔚 Closing database connection..."); 47 | process.exit(0); 48 | } 49 | } 50 | 51 | // Handle process termination gracefully 52 | process.on('SIGINT', () => { 53 | console.log('\n👋 Received SIGINT. Gracefully shutting down...'); 54 | process.exit(0); 55 | }); 56 | 57 | process.on('SIGTERM', () => { 58 | console.log('\n👋 Received SIGTERM. Gracefully shutting down...'); 59 | process.exit(0); 60 | }); 61 | 62 | main().catch((error) => { 63 | console.error("💥 Unhandled error:", error); 64 | process.exit(1); 65 | }); -------------------------------------------------------------------------------- /src/lib/hooks/use-router-stuff.ts: -------------------------------------------------------------------------------- 1 | import { usePathname, useRouter, useSearchParams } from "next/navigation"; 2 | 3 | export function useRouterStuff() { 4 | const pathname = usePathname(); 5 | const router = useRouter(); 6 | const searchParams = useSearchParams()!; 7 | const searchParamsObj = Object.fromEntries(searchParams); 8 | 9 | const getQueryString = ( 10 | kv?: Record, 11 | opts?: { 12 | ignore?: string[]; 13 | }, 14 | ) => { 15 | const newParams = new URLSearchParams(searchParams); 16 | if (kv) { 17 | Object.entries(kv).forEach(([k, v]) => newParams.set(k, v)); 18 | } 19 | if (opts?.ignore) { 20 | opts.ignore.forEach((k) => newParams.delete(k)); 21 | } 22 | const queryString = newParams.toString(); 23 | return queryString.length > 0 ? `?${queryString}` : ""; 24 | }; 25 | 26 | const queryParams = ({ 27 | set, 28 | del, 29 | replace, 30 | getNewPath, 31 | arrayDelimiter = ",", 32 | }: { 33 | set?: Record; 34 | del?: string | string[]; 35 | replace?: boolean; 36 | getNewPath?: boolean; 37 | arrayDelimiter?: string; 38 | }) => { 39 | const newParams = new URLSearchParams(searchParams); 40 | if (set) { 41 | Object.entries(set).forEach(([k, v]) => 42 | newParams.set(k, Array.isArray(v) ? v.join(arrayDelimiter) : v), 43 | ); 44 | } 45 | if (del) { 46 | if (Array.isArray(del)) { 47 | del.forEach((k) => newParams.delete(k)); 48 | } else { 49 | newParams.delete(del); 50 | } 51 | } 52 | const queryString = newParams.toString(); 53 | const newPath = `${pathname}${queryString.length > 0 ? `?${queryString}` : "" 54 | }`; 55 | if (getNewPath) return newPath; 56 | if (replace) { 57 | router.replace(newPath, { scroll: false }); 58 | } else { 59 | router.push(newPath); 60 | } 61 | }; 62 | 63 | return { 64 | pathname, 65 | router, 66 | searchParams, 67 | searchParamsObj, 68 | queryParams, 69 | getQueryString, 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /src/components/ui/background.tsx: -------------------------------------------------------------------------------- 1 | export function Background() { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 | ); 9 | } 10 | 11 | const styles: { [key: string]: React.CSSProperties } = { 12 | backgroundMain: { 13 | width: "100vw", 14 | minHeight: "100vh", 15 | position: "fixed", 16 | zIndex: -1, 17 | display: "flex", 18 | justifyContent: "center", 19 | padding: "120px 24px 160px 24px", 20 | pointerEvents: "none", 21 | }, 22 | backgroundMainBefore: { 23 | background: "radial-gradient(circle, rgba(2, 0, 36, 0) 0, #fafafa 100%)", 24 | position: "absolute", 25 | content: '""', 26 | zIndex: 2, 27 | width: "100%", 28 | height: "100%", 29 | top: 0, 30 | }, 31 | backgroundMainAfter: { 32 | content: '""', 33 | backgroundImage: "url(_static/grid.svg)", 34 | zIndex: 1, 35 | position: "absolute", 36 | width: "100%", 37 | height: "100%", 38 | top: 0, 39 | opacity: 0.4, 40 | filter: "invert(1)", 41 | }, 42 | backgroundContent: { 43 | zIndex: 3, 44 | width: "100%", 45 | maxWidth: "640px", 46 | backgroundImage: `radial-gradient(at 27% 37%, hsla(215, 98%, 61%, 1) 0px, transparent 0%), 47 | radial-gradient(at 97% 21%, hsla(125, 98%, 72%, 1) 0px, transparent 50%), 48 | radial-gradient(at 52% 99%, hsla(354, 98%, 61%, 1) 0px, transparent 50%), 49 | radial-gradient(at 10% 29%, hsla(256, 96%, 67%, 1) 0px, transparent 50%), 50 | radial-gradient(at 97% 96%, hsla(38, 60%, 74%, 1) 0px, transparent 50%), 51 | radial-gradient(at 33% 50%, hsla(222, 67%, 73%, 1) 0px, transparent 50%), 52 | radial-gradient(at 79% 53%, hsla(343, 68%, 79%, 1) 0px, transparent 50%)`, 53 | position: "absolute", 54 | height: "100%", 55 | filter: "blur(100px) saturate(150%)", 56 | top: "80px", 57 | opacity: 0.15, 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /public/_static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 22 | 30 | 38 | 46 | -------------------------------------------------------------------------------- /src/components/ui/model.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import * as Dialog from "@radix-ui/react-dialog"; 5 | import { useRouter } from "next/navigation"; 6 | import { Dispatch, SetStateAction } from "react"; 7 | 8 | export function Modal({ 9 | children, 10 | className, 11 | showModal, 12 | setShowModal, 13 | onClose, 14 | desktopOnly, 15 | preventDefaultClose, 16 | }: { 17 | children: React.ReactNode; 18 | className?: string; 19 | showModal?: boolean; 20 | setShowModal?: Dispatch>; 21 | onClose?: () => void; 22 | desktopOnly?: boolean; 23 | preventDefaultClose?: boolean; 24 | }) { 25 | const router = useRouter(); 26 | 27 | const closeModal = ({ dragged }: { dragged?: boolean } = {}) => { 28 | if (preventDefaultClose && !dragged) { 29 | return; 30 | } 31 | // fire onClose event if provided 32 | onClose && onClose(); 33 | 34 | // if setShowModal is defined, use it to close modal 35 | if (setShowModal) { 36 | setShowModal(false); 37 | // else, this is intercepting route @modal 38 | } else { 39 | router.back(); 40 | } 41 | }; 42 | 43 | return ( 44 | { 47 | if (!open) { 48 | closeModal(); 49 | } 50 | }} 51 | > 52 | 53 | 58 | e.preventDefault()} 60 | onCloseAutoFocus={(e) => e.preventDefault()} 61 | className={cn( 62 | "animate-scale-in fixed inset-0 z-40 m-auto max-h-fit w-full max-w-md overflow-hidden border border-border bg-background p-0 shadow-xl sm:rounded-2xl", 63 | className, 64 | )} 65 | > 66 | {children} 67 | 68 | 69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/components/molecule/text-field.tsx: -------------------------------------------------------------------------------- 1 | import cx from "classnames"; 2 | import { CircleHelpIcon } from "lucide-react"; 3 | import React from "react"; 4 | import { Input } from "../ui/input"; 5 | import { ToolTipWrapper } from "../ui/tooltip"; 6 | 7 | export interface InputProps 8 | extends React.InputHTMLAttributes { 9 | wrapperClassName?: string; 10 | label?: string; 11 | error?: string; 12 | hint?: React.ReactNode; 13 | } 14 | 15 | /** 16 | * Text field component 17 | * @param {*} prop 18 | * @constructor 19 | * @example 20 | * setName(e.target.value)} /> 21 | */ 22 | const TextField = React.forwardRef( 23 | ({ className, wrapperClassName, hint, label, error, ...props }, ref) => { 24 | return ( 25 |
26 | 40 |
41 | 51 | 59 |
60 |
61 | ); 62 | }, 63 | ); 64 | 65 | TextField.displayName = "TextField"; 66 | 67 | export { TextField }; 68 | -------------------------------------------------------------------------------- /src/components/ui/editor/selectors/text-buttons.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { cn } from "@/lib/utils"; 3 | import { 4 | BoldIcon, 5 | CodeIcon, 6 | ItalicIcon, 7 | StrikethroughIcon, 8 | UnderlineIcon, 9 | } from "lucide-react"; 10 | import { EditorBubbleItem, useEditor } from "novel"; 11 | import type { SelectorItem } from "./node-selector"; 12 | 13 | export const TextButtons = () => { 14 | const { editor } = useEditor(); 15 | if (!editor) return null; 16 | const items: SelectorItem[] = [ 17 | { 18 | name: "bold", 19 | isActive: (editor) => editor!.isActive("bold"), 20 | command: (editor) => editor!.chain().focus().toggleBold().run(), 21 | icon: BoldIcon, 22 | }, 23 | { 24 | name: "italic", 25 | isActive: (editor) => editor!.isActive("italic"), 26 | command: (editor) => editor!.chain().focus().toggleItalic().run(), 27 | icon: ItalicIcon, 28 | }, 29 | { 30 | name: "underline", 31 | isActive: (editor) => editor!.isActive("underline"), 32 | command: (editor) => editor!.chain().focus().toggleUnderline().run(), 33 | icon: UnderlineIcon, 34 | }, 35 | { 36 | name: "strike", 37 | isActive: (editor) => editor!.isActive("strike"), 38 | command: (editor) => editor!.chain().focus().toggleStrike().run(), 39 | icon: StrikethroughIcon, 40 | }, 41 | { 42 | name: "code", 43 | isActive: (editor) => editor!.isActive("code"), 44 | command: (editor) => editor!.chain().focus().toggleCode().run(), 45 | icon: CodeIcon, 46 | }, 47 | ]; 48 | return ( 49 |
50 | {items.map((item, index) => ( 51 | { 54 | item.command(editor); 55 | }} 56 | > 57 | 64 | 65 | ))} 66 |
67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /scripts/workspace/assign-editor-to-workspaces.ts: -------------------------------------------------------------------------------- 1 | import mongodb, { databaseName } from "@/lib/mongodb"; 2 | import { WorkspaceMemberDBSchema, WorkspaceDbSchema } from "@/lib/db-schema/workspace.schema"; 3 | import "dotenv-flow/config"; 4 | import { ObjectId } from "mongodb"; 5 | 6 | 7 | // npm run script workspace/assign-editor-to-workspaces 8 | // npx tsx ./scripts/workspace/assign-editor-to-workspaces.ts 9 | 10 | // Script to assign default editor to workspaces 11 | async function main() { 12 | const client = await mongodb; 13 | const workspaceCollection = client.db(databaseName).collection("workspaces"); 14 | const workspaceUserColl = client.db(databaseName).collection("workspace_users"); 15 | 16 | console.log("\n-------------------- workspaces --------------------\n"); 17 | 18 | // @ts-ignore 19 | const workspaces = await workspaceCollection.find().toArray() as WorkspaceDbSchema[]; 20 | 21 | console.log(`Total ${workspaces.length} workspaces available\n`); 22 | 23 | if (workspaces.length > 0) { 24 | 25 | let insertResult = 0; 26 | for (let i = 0; i < workspaces.length; i++) { 27 | const workspace = workspaces[i]; 28 | const workspaceMembers = await workspaceUserColl.find({ workspace: new ObjectId(workspace._id) }).toArray(); 29 | if (workspaceMembers.length === 0) { 30 | const user = { 31 | role: 'editor', 32 | team: new ObjectId(workspace.team), 33 | workspace: new ObjectId(workspace._id), 34 | user: new ObjectId(workspace.createdBy), 35 | createdAt: new Date(), 36 | updatedAt: new Date(), 37 | } as WorkspaceMemberDBSchema; 38 | 39 | await workspaceUserColl.insertOne(user); 40 | insertResult++; 41 | } 42 | } 43 | if (insertResult > 0) { 44 | console.log(`✅ ${insertResult} workspaces now have at least one member.`); 45 | } 46 | else { 47 | console.log("All workspaces have at least one member"); 48 | } 49 | } else { 50 | console.log("No workspaces found."); 51 | } 52 | console.log("------------------------------------------------\n"); 53 | client.close(); 54 | process.exit(0); 55 | } 56 | 57 | 58 | main(); 59 | -------------------------------------------------------------------------------- /src/app/(dashboard)/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ReactNode, createContext } from "react"; 4 | 5 | import { useToast } from "@/components/ui/use-toast"; 6 | import { fetcher } from "@/lib/fetcher"; 7 | import useTeams from "@/lib/swr/use-teams"; 8 | import { Team } from "@/lib/types/types"; 9 | import { SessionProvider } from "next-auth/react"; 10 | 11 | export default function Providers({ children }: { children: ReactNode }) { 12 | return ( 13 | 14 | {children} 15 | {/* {children} */} 16 | 17 | ); 18 | } 19 | 20 | interface DashBoardProviderProps { 21 | loading?: boolean; 22 | error?: any; 23 | teams?: Team[]; 24 | createTeam: (name: string, description: string) => Promise; 25 | } 26 | 27 | export const DashBoardContext2 = createContext( 28 | null, 29 | ) as unknown as React.Context; 30 | DashBoardContext2.displayName = "DashBoardContext2"; 31 | 32 | function DashBoardProvider({ children }: { children: ReactNode }) { 33 | const { error, loading, teams, mutate } = useTeams(); 34 | const { toast } = useToast(); 35 | 36 | // Create Team 37 | async function createTeam(name: string, description: string) { 38 | try { 39 | const response = await fetcher("/api/teams/create", { 40 | method: "POST", 41 | body: JSON.stringify({ name, description }), 42 | }); 43 | document.getElementById("CreateTeamCloseDialogButton")?.click(); 44 | mutate({ data: [...teams, response.team] }, false); 45 | toast({ 46 | title: "Team created", 47 | description: "Team has been created", 48 | variant: "default", 49 | }); 50 | } catch (error: any) { 51 | toast({ 52 | title: "Error", 53 | description: error.message ?? `Error occurred while creating Team`, 54 | duration: 2000, 55 | variant: "destructive", 56 | }); 57 | } 58 | } 59 | 60 | return ( 61 | 69 | {children} 70 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/components/team/workspace/workspace-view.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | import { Spinner } from "@/components/atom/spinner"; 6 | import useWorkspaces from "@/lib/swr/use-workspaces"; 7 | import NotFoundView from "../team-not-found"; 8 | import CollectionPanel from "./left-panel/collection-panel.view"; 9 | import WorkspaceContentView from "./workspace-content-view"; 10 | 11 | export const LeftPanelSize = Object.freeze({ 12 | min: 0, 13 | default: 320, 14 | large: typeof window !== "undefined" ? window?.innerWidth / 2 ?? 500 : 500, 15 | max: typeof window !== "undefined" ? window?.innerWidth : 1000, 16 | }); 17 | 18 | /** 19 | * Displays the workspace view 20 | */ 21 | export default function WorkspaceView({ 22 | children, 23 | }: { 24 | children?: React.ReactNode; 25 | }) { 26 | const { activeWorkspace, loading, error } = useWorkspaces(); 27 | 28 | const [leftPanelSize, setLeftPanelSize] = useState(LeftPanelSize.min); 29 | 30 | useEffect(() => { 31 | if (activeWorkspace) { 32 | setLeftPanelSize(LeftPanelSize.default); 33 | } 34 | }, [activeWorkspace]); 35 | 36 | if (loading) { 37 | return ( 38 |
39 | 40 |
41 | ); 42 | } else if (error) { 43 | return ( 44 |
45 | Something went wrong 46 |
47 | ); 48 | } else if (!activeWorkspace) { 49 | return ( 50 |
51 | 52 |
53 | ); 54 | } 55 | 56 | return ( 57 | 66 | } 67 | > 68 |
69 | {children} 70 |
71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /scripts/workspace/add-default-values.ts: -------------------------------------------------------------------------------- 1 | import mongodb, { databaseName } from "@/lib/mongodb"; 2 | import { WorkspaceDbSchema } from "@/lib/db-schema/workspace.schema"; 3 | import "dotenv-flow/config"; 4 | 5 | 6 | // npm run script /workspace/add-default-values 7 | // npx tsx ./scripts/workspace/add-default-values.ts 8 | async function main() { 9 | const client = await mongodb; 10 | const workspaceCollection = client.db(databaseName).collection("workspaces"); 11 | 12 | console.log("\n-------------------- workspaces --------------------\n"); 13 | 14 | // @ts-ignore 15 | const workspaces = await workspaceCollection.find({ 16 | $or: [ 17 | { "defaultAccess": { $in: [null, undefined, ''] } }, 18 | { "visibility": { $in: [null, undefined, '', 'Public', 'Private'] }, } 19 | ] 20 | }).toArray(); 21 | 22 | if (workspaces.length > 0) { 23 | console.log(`❌ ${workspaces.length} workspaces don't have default values.\n`); 24 | 25 | const bulk = await workspaceCollection.bulkWrite([ 26 | { 27 | updateMany: { 28 | // @ts-ignore 29 | filter: { "defaultAccess": { $in: [null, undefined, ''] } }, 30 | update: { $set: { "defaultAccess": "full" } } 31 | }, 32 | }, 33 | { 34 | updateMany: { 35 | // @ts-ignore 36 | filter: { "visibility": { $in: [null, undefined, ''] } }, 37 | update: { $set: { "visibility": "public" } } 38 | } 39 | }, 40 | { 41 | updateMany: { 42 | // @ts-ignore 43 | filter: { "visibility": { $in: ['Public'] } }, 44 | update: { $set: { "visibility": "public" } } 45 | } 46 | }, 47 | { 48 | updateMany: { 49 | // @ts-ignore 50 | filter: { "visibility": { $in: ['Private'] } }, 51 | update: { $set: { "visibility": "private" } } 52 | } 53 | } 54 | ]); 55 | console.log({ Result: bulk }); 56 | console.log("✅ Default values set for workspaces."); 57 | 58 | 59 | } else { 60 | console.log("✅ All workspaces have default values."); 61 | 62 | } 63 | console.log("------------------------------------------------\n"); 64 | client.close(); 65 | process.exit(0); 66 | } 67 | 68 | 69 | main(); 70 | -------------------------------------------------------------------------------- /src/lib/analytics/provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useEffect, useMemo, useState } from "react"; 4 | import { getEnvironment } from "../constants/constants"; 5 | import { AnalyticsContext } from "./context"; 6 | import { AnalyticsConfig, AnalyticsProvider } from "./interfaces"; 7 | import { MixpanelAnalytics } from "./providers/mixpanel"; 8 | 9 | const defaultConfig: AnalyticsConfig = { 10 | enabled: true, //process.env.NODE_ENV === "production", 11 | debug: process.env.NEXT_PUBLIC_VERCEL_ENV === "production", //process.env.NODE_ENV !== "production", 12 | batchSize: 20, 13 | flushInterval: 10000, 14 | retryAttempts: 3, 15 | environment: getEnvironment(), 16 | validateEvents: true, 17 | enableOfflineQueue: true, 18 | maxQueueSize: 1000, 19 | provider: "mixpanel", 20 | }; 21 | 22 | export const AnalyticsProviderComponent: React.FC<{ 23 | children: React.ReactNode; 24 | config?: Partial; 25 | }> = ({ children, config }) => { 26 | const [isReady, setIsReady] = useState(false); 27 | 28 | const finalConfig = useMemo( 29 | () => ({ ...defaultConfig, ...config }), 30 | [config], 31 | ); 32 | 33 | const provider = useMemo(() => { 34 | if ( 35 | typeof window !== "undefined" && 36 | process.env.NEXT_PUBLIC_MIXPANEL_TOKEN 37 | ) { 38 | return new MixpanelAnalytics( 39 | process.env.NEXT_PUBLIC_MIXPANEL_TOKEN, 40 | finalConfig, 41 | ); 42 | } 43 | // Return a no-op provider if on server or no token 44 | const noOpProvider: AnalyticsProvider = { 45 | track: async () => {}, 46 | identify: async () => {}, 47 | page: async () => {}, 48 | group: async () => {}, 49 | reset: async () => {}, 50 | flushQueue: async () => {}, 51 | getQueueSize: () => 0, 52 | clearQueue: () => {}, 53 | isHealthy: () => true, 54 | }; 55 | return noOpProvider; 56 | }, [finalConfig]); 57 | 58 | useEffect(() => { 59 | if (provider) { 60 | setIsReady(true); 61 | } 62 | }, [provider]); 63 | 64 | return ( 65 | 72 | {children} 73 | 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /src/app/api/user/tokens/route.ts: -------------------------------------------------------------------------------- 1 | import mongoDb, { databaseName } from "@/lib/mongodb"; 2 | import { ObjectId } from "mongodb"; 3 | import { hashToken, withSession } from "@/lib/auth"; 4 | // import { qstash } from "@/lib/cron"; 5 | import { NextResponse } from "next/server"; 6 | import { randomId } from "@/lib/utils"; 7 | 8 | // GET /api/user/tokens – get all tokens for a specific user 9 | export const GET = withSession(async ({ session }) => { 10 | const client = await mongoDb; 11 | const tokenCollection = client.db(databaseName).collection("token"); 12 | const tokens = await tokenCollection.find({ user: new ObjectId(session.user.id) }, { 13 | projection: { 14 | name: 1, 15 | partialKey: 1, 16 | createdAt: 1, 17 | lastUsed: 1, 18 | }, 19 | sort: { 20 | lastUsed: -1, 21 | createdAt: -1, 22 | }, 23 | 24 | }).toArray(); 25 | return NextResponse.json(tokens); 26 | }); 27 | 28 | // POST /api/user/tokens – create a new token for a specific user 29 | export const POST = withSession(async ({ req, session }) => { 30 | const { name } = await req.json(); 31 | const client = await mongoDb; 32 | const tokenCollection = client.db(databaseName).collection("token"); 33 | const token = randomId(24); 34 | const hashedKey = hashToken(token, { 35 | noSecret: true, 36 | }); 37 | // take first 3 and last 4 characters of the key 38 | const partialKey = `${token.slice(0, 3)}...${token.slice(-4)}`; 39 | await tokenCollection.insertOne({ 40 | name, 41 | hashedKey, 42 | partialKey, 43 | user: new ObjectId(session.user.id), 44 | createdAt: new Date(), 45 | lastUsed: new Date(), 46 | }); 47 | // TODO: Send a notification mail using cron 48 | return NextResponse.json({ token }); 49 | }); 50 | 51 | // DELETE /api/user/tokens – delete a token for a specific user 52 | export const DELETE = withSession(async ({ searchParams, session }) => { 53 | const { id } = searchParams; 54 | const client = await mongoDb; 55 | const tokenCollection = client.db(databaseName).collection("token"); 56 | const response = await tokenCollection.deleteOne({ _id: new ObjectId(id), user: new ObjectId(session.user.id) }); 57 | return NextResponse.json({ 58 | success: response.deletedCount === 1, 59 | message: response.deletedCount === 1 ? "Token deleted successfully" : "Token not found", 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/components/ui/icons/font-default.tsx: -------------------------------------------------------------------------------- 1 | export default function FontDefault({ className }: { className?: string }) { 2 | return ( 3 | 10 | 14 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/atom/logo.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | export function Logo({ className }: { className?: string }) { 4 | return ( 5 | 16 | 24 | 32 | 40 | 48 | 49 | ); 50 | } 51 | 52 | export function BrandLabel({ className }: { className?: string }) { 53 | return ( 54 | 55 |

Orgnise

56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/components/team/workspace/collection/content/content.tsx: -------------------------------------------------------------------------------- 1 | import Label from "@/components/atom/label"; 2 | import { Button } from "@/components/ui/button"; 3 | import NovelEditor from "@/components/ui/editor/editor"; 4 | import { Collection } from "@/lib/types/types"; 5 | import { hasValue } from "@/lib/utils"; 6 | import { Editor as Editor$1 } from "@tiptap/core"; 7 | 8 | interface CollectionContentProps { 9 | activeItem?: Collection; 10 | editable?: boolean; 11 | onDebouncedUpdate?: 12 | | ((editor?: Editor$1 | undefined) => void | Promise) 13 | | undefined; 14 | } 15 | export default function ItemContent({ 16 | activeItem, 17 | editable = true, 18 | onDebouncedUpdate, 19 | }: CollectionContentProps) { 20 | if (!hasValue(activeItem)) { 21 | return ( 22 |
23 | 24 | Items are{" "} 25 | 28 | that help you capture knowledge. For example, a{" "} 29 | 32 | item could contain decisions made in a meeting. Items can be grouped 33 | and nested with collections. 34 | 35 | 44 |
45 | ); 46 | } 47 | 48 | if (!activeItem) { 49 | return ( 50 |
51 | 54 | 55 | 58 |
59 | ); 60 | } 61 | return ( 62 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/app/api/callback/stripe/route.ts: -------------------------------------------------------------------------------- 1 | import { log } from "@/lib/functions/log"; 2 | import mongodb from "@/lib/mongodb"; 3 | import { stripe } from "@/lib/stripe"; 4 | import { NextResponse } from "next/server"; 5 | import Stripe from "stripe"; 6 | import { checkoutSessionCompleted } from "./checkout-session-complete"; 7 | import { customerSubscriptionDeleted } from "./customer-subscription-deleted"; 8 | import { customerSubscriptionUpdated } from "./customer-subscription-updated"; 9 | 10 | const relevantEvents = new Set([ 11 | "checkout.session.completed", 12 | "customer.subscription.updated", 13 | "customer.subscription.deleted", 14 | ]); 15 | 16 | // POST /api/callback/stripe – listen to Stripe webhooks 17 | export const POST = async (req: Request) => { 18 | try { 19 | const buf = await req.text(); 20 | const sig = req.headers.get("Stripe-Signature") as string; 21 | const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; 22 | let event: Stripe.Event; 23 | 24 | 25 | 26 | try { 27 | if (!sig || !webhookSecret) return; 28 | event = stripe.webhooks.constructEvent(buf, sig, webhookSecret); 29 | } catch (err: any) { 30 | console.log(`❌ Error message: ${err.message}`); 31 | return new Response(`Webhook Error: ${err.message}`, { 32 | status: 400, 33 | }); 34 | } 35 | if (relevantEvents.has(event.type)) { 36 | console.log('👉 Stripe webhook event type:', event.type); 37 | const client = await mongodb; 38 | if (event.type === "checkout.session.completed") { 39 | await checkoutSessionCompleted(event, client); 40 | } 41 | 42 | // for subscription updates 43 | else if (event.type === "customer.subscription.updated") { 44 | await customerSubscriptionUpdated(event, client); 45 | } 46 | 47 | // If team cancels their subscription 48 | else if (event.type === "customer.subscription.deleted") { 49 | await customerSubscriptionDeleted(event, client); 50 | } 51 | } 52 | return NextResponse.json({ received: true }); 53 | } catch (error: any) { 54 | await log({ 55 | message: `Stripe webhook failed. Error: ${error.message}`, 56 | type: "errors", 57 | }); 58 | return new Response( 59 | 'Webhook error: "Webhook handler failed. View logs."', 60 | { 61 | status: 400, 62 | }, 63 | ); 64 | } 65 | }; -------------------------------------------------------------------------------- /src/components/ui/popover-2.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useMediaQuery } from "@/lib/hooks"; 4 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 5 | import { Dispatch, ReactNode, SetStateAction } from "react"; 6 | import { Drawer } from "vaul"; 7 | 8 | export function Popover2({ 9 | children, 10 | content, 11 | align = "center", 12 | openPopover, 13 | setOpenPopover, 14 | mobileOnly, 15 | }: { 16 | children: ReactNode; 17 | content: ReactNode | string; 18 | align?: "center" | "start" | "end"; 19 | openPopover: boolean; 20 | setOpenPopover: Dispatch>; 21 | mobileOnly?: boolean; 22 | }) { 23 | const { isMobile } = useMediaQuery(); 24 | 25 | if (mobileOnly || isMobile) { 26 | return ( 27 | 28 | 29 | {children} 30 | 31 | 32 | 33 | 34 |
35 |
36 |
37 |
38 | {content} 39 |
40 | 41 | 42 | 43 | 44 | ); 45 | } 46 | 47 | return ( 48 | 49 | 50 | {children} 51 | 52 | 53 | 58 | {content} 59 | 60 | 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[team_slug]/[workspace_slug]/settings/people/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Tab from "@/components/atom/tab"; 4 | import { WorkspacePermissionView } from "@/components/molecule/workspace-permission-view"; 5 | import WorkspaceMembers from "@/components/team/workspace/settings/workspace-members"; 6 | import { Button } from "@/components/ui/button"; 7 | import { useAddWorkspaceModal } from "@/components/ui/models"; 8 | import useTeam from "@/lib/swr/use-team"; 9 | import { useState } from "react"; 10 | 11 | const tabs: Array<"Members"> = ["Members"]; 12 | 13 | export default function ProjectPeopleClient() { 14 | const { activeTeam } = useTeam(); 15 | const [currentTab, setCurrentTab] = useState<"Members">("Members"); 16 | const { AddWorkspaceMembersModal, setShowAddWorkspaceMembersModal } = 17 | useAddWorkspaceModal(); 18 | return ( 19 | <> 20 | 21 |
22 |
23 |
24 |

People

25 |

26 | Teammates that have access to this workspace. 27 |

28 |
29 | 33 | 39 | 40 |
41 |
42 | {tabs.map((tab, index) => ( 43 | 47 | setCurrentTab(tab)} 52 | /> 53 | 54 | ))} 55 |
56 | 57 |
58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /emails/login-link.tsx: -------------------------------------------------------------------------------- 1 | import { ORGNISE_LOGO } from "@/lib/constants"; 2 | import { 3 | Body, 4 | Container, 5 | Head, 6 | Heading, 7 | Html, 8 | Img, 9 | Link, 10 | Preview, 11 | Section, 12 | Tailwind, 13 | Text, 14 | } from "@react-email/components"; 15 | import Footer from "./component/footer"; 16 | 17 | export default function LoginLink({ 18 | email = "john@doe.io", 19 | url = "http://localhost:3000/api/auth/callback/email?callbackUrl=http%3A%2F%2Fapp.localhost%3A3000%2Flogin&token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&email=youremail@gmail.com", 20 | }: { 21 | email: string; 22 | url: string; 23 | }) { 24 | return ( 25 | 26 | 27 | Your Orgnise Login Link 28 | 29 | 30 | 31 |
32 | Orgnise 39 |
40 | 41 | Your Login Link 42 | 43 | 44 | Welcome to Orgnise! 45 | 46 | 47 | Please click the magic link below to sign in to your account. 48 | 49 |
50 | 54 | Sign in 55 | 56 |
57 | 58 | or copy and paste this URL into your browser: 59 | 60 | 61 | {url.replace(/^https?:\/\//, "")} 62 | 63 |