diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e2d4155 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Supabase Configuration +VITE_SUPABASE_URL=your_supabase_url +VITE_SUPABASE_ANON_KEY=your_supabase_anon_key + +# Sentry Configuration (Optional) +VITE_SENTRY_DSN=your_sentry_dsn + +# Drawing Limit (Optional) +# Set the maximum number of drawings a user can create +# If not set, users can create unlimited drawings +# Example: VITE_MAX_DRAWINGS_PER_USER=10 +# VITE_MAX_DRAWINGS_PER_USER= + +# Unlimited Users (Optional) +# Comma-separated list of user emails that bypass the drawing limit +# These users can create unlimited drawings even if VITE_MAX_DRAWINGS_PER_USER is set +# Example: VITE_UNLIMITED_USERS=admin@example.com,manager@example.com +# VITE_UNLIMITED_USERS= diff --git a/README.md b/README.md index 7180344..e4d9ae2 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Draw is a wrapper around Excalidraw, integrated with Supabase to save and sync y * Cloud Sync: Uses Supabase for authentication and storage, ensuring secure access and synchronization of your drawings. + Folders: Organize drawings into folders. + Sidebar: Navigate and manage folders and drawings from a sleek sidebar. ++ Drawing Limit: Optional environment variable to limit the number of drawings per user. - Clunky UI: Removed clunky, unnecessary ui components from the UI. ``` @@ -31,6 +32,27 @@ We have set up a one-click deploy to Vercel. [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/KainoaNewton/draw) +### Drawing Limit (Optional) + +You can optionally limit the number of drawings a user can create by setting the `VITE_MAX_DRAWINGS_PER_USER` environment variable in your Vercel deployment (or any other hosting platform). + +For example, to limit users to 10 drawings: +- In Vercel: Go to your project settings → Environment Variables → Add `VITE_MAX_DRAWINGS_PER_USER` with value `10` +- In `.env` file: Add `VITE_MAX_DRAWINGS_PER_USER=10` + +If this environment variable is not set, users can create unlimited drawings (default behavior). + +#### Unlimited Users Bypass List + +You can also specify a list of user emails that bypass the drawing limit by setting the `VITE_UNLIMITED_USERS` environment variable. This is useful for administrators or premium users who should have unlimited access. + +For example: +- In Vercel: Add `VITE_UNLIMITED_USERS` with value `admin@example.com,manager@example.com,premium@example.com` +- In `.env` file: Add `VITE_UNLIMITED_USERS=admin@example.com,manager@example.com` + +Users in this list can create unlimited drawings even when `VITE_MAX_DRAWINGS_PER_USER` is set. The comparison is case-insensitive and supports multiple emails separated by commas. + + If you want to deploy using Docker, you can use the provided docker-compose file, using the instruction in the [Docker](https://github.com/kainoanewton/draw/blob/main/docs/docker.md) section. If you'd like to build the app yourself, run: diff --git a/src/components/SearchCommand.tsx b/src/components/SearchCommand.tsx index ad9002f..fa0fbbe 100644 --- a/src/components/SearchCommand.tsx +++ b/src/components/SearchCommand.tsx @@ -91,7 +91,13 @@ export function SearchCommand({ open, onOpenChange }: SearchCommandProps) { const response = await createNewPage(undefined, targetFolderId); if (response.error) { - toast.error("Failed to create page"); + if (response.error.code === 'DrawingLimitReached') { + toast.error("Drawing limit reached", { + description: response.error.message, + }); + } else { + toast.error("Failed to create page"); + } return; } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 72d251e..3a7cad3 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -468,9 +468,15 @@ export default function Sidebar({ className }: SidebarProps) { toast("Successfully created a new page!"); } if (data.error) { - toast("An error occurred", { - description: `Error: ${data.error.message}`, - }); + if (data.error.code === 'DrawingLimitReached') { + toast.error("Drawing limit reached", { + description: data.error.message, + }); + } else { + toast("An error occurred", { + description: `Error: ${data.error.message}`, + }); + } } } @@ -499,9 +505,15 @@ export default function Sidebar({ className }: SidebarProps) { toast("Successfully created a new drawing!"); } if (data.error) { - toast("An error occurred", { - description: `Error: ${data.error.message}`, - }); + if (data.error.code === 'DrawingLimitReached') { + toast.error("Drawing limit reached", { + description: data.error.message, + }); + } else { + toast("An error occurred", { + description: `Error: ${data.error.message}`, + }); + } } } diff --git a/src/db/draw.ts b/src/db/draw.ts index 5bd89a1..3b71f09 100644 --- a/src/db/draw.ts +++ b/src/db/draw.ts @@ -11,6 +11,29 @@ export type DBResponse = { export const DB_NAME = "draw"; export const FOLDERS_DB_NAME = "folders"; +// Cache parsed unlimited users list to avoid repeated parsing +let cachedUnlimitedUsers: string[] = []; +let cachedUnlimitedUsersEnv: string | undefined = undefined; + +function getUnlimitedUsers(): string[] { + const unlimitedUsersEnv = import.meta.env.VITE_UNLIMITED_USERS; + + // Return cached list if environment variable hasn't changed + if (unlimitedUsersEnv === cachedUnlimitedUsersEnv) { + return cachedUnlimitedUsers; + } + + // Parse and cache the list + cachedUnlimitedUsersEnv = unlimitedUsersEnv; + if (unlimitedUsersEnv) { + cachedUnlimitedUsers = unlimitedUsersEnv.split(',').map((email: string) => email.trim().toLowerCase()); + } else { + cachedUnlimitedUsers = []; + } + + return cachedUnlimitedUsers; +} + export type Folder = { folder_id: string; name: string; @@ -46,6 +69,52 @@ export async function createNewPage( ): Promise { const { data: profile, error: profileError } = await supabase.auth.getUser(); if (profile) { + // Check drawing limit if environment variable is set + const maxDrawings = import.meta.env.VITE_MAX_DRAWINGS_PER_USER; + if (maxDrawings) { + const limit = parseInt(maxDrawings, 10); + if (!isNaN(limit) && limit > 0) { + // Check if user is in the unlimited users list (bypass list) + const userEmail = profile.user?.email; + let isUnlimitedUser = false; + + if (userEmail) { + const unlimitedUsers = getUnlimitedUsers(); + isUnlimitedUser = unlimitedUsers.includes(userEmail.toLowerCase()); + } + + // Only enforce limit if user is not in the unlimited users list + if (!isUnlimitedUser) { + // Count existing non-deleted drawings for this user + // Using .not() to exclude only explicitly deleted drawings, including NULL as non-deleted + const { count, error: countError } = await supabase + .from(DB_NAME) + .select('page_id', { count: 'exact', head: true }) + .eq("user_id", profile.user?.id) + .not('is_deleted', 'eq', true); + + if (countError) { + return { data: null, error: countError }; + } + + // Log for debugging + console.log(`Drawing limit check: user has ${count} drawings, limit is ${limit}`); + + if (count !== null && count >= limit) { + // Create a custom error to indicate limit reached + const limitError: PostgrestError = { + message: `You have reached the maximum limit of ${limit} drawing${limit === 1 ? '' : 's'} (you currently have ${count}). Please delete some drawings to create new ones.`, + details: '', + hint: '', + code: 'DrawingLimitReached', + name: 'PostgrestError', + }; + return { data: null, error: limitError }; + } + } + } + } + let finalFolderId = folder_id; // If no folder_id provided, get or create default folder diff --git a/src/views/Pages.tsx b/src/views/Pages.tsx index ac5df98..f9a3540 100644 --- a/src/views/Pages.tsx +++ b/src/views/Pages.tsx @@ -221,9 +221,15 @@ export default function Pages() { navigate({ to: "/page/$id", params: { id: data.data[0].page_id } }); } if (data.error) { - toast("An error occurred", { - description: `Error: ${data.error.message}`, - }); + if (data.error.code === 'DrawingLimitReached') { + toast.error("Drawing limit reached", { + description: data.error.message, + }); + } else { + toast("An error occurred", { + description: `Error: ${data.error.message}`, + }); + } } }