diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.template.env b/.template.env new file mode 100644 index 0000000..6b592ac --- /dev/null +++ b/.template.env @@ -0,0 +1,9 @@ +# Deployment used by `npx convex dev` +CONVEX_DEPLOYMENT= + +VITE_CONVEX_URL= + +VITE_CLERK_PUBLISHABLE_KEY= + +VITE_POSTHOG_KEY= +VITE_POSTHOG_API_HOST= diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..0a82899 Binary files /dev/null and b/bun.lockb differ diff --git a/convex/README.md b/convex/README.md new file mode 100644 index 0000000..4d82e13 --- /dev/null +++ b/convex/README.md @@ -0,0 +1,90 @@ +# Welcome to your Convex functions directory! + +Write your Convex functions here. +See https://docs.convex.dev/functions for more. + +A query function that takes two arguments looks like: + +```ts +// functions.js +import { query } from "./_generated/server"; +import { v } from "convex/values"; + +export const myQueryFunction = query({ + // Validators for arguments. + args: { + first: v.number(), + second: v.string(), + }, + + // Function implementation. + handler: async (ctx, args) => { + // Read the database as many times as you need here. + // See https://docs.convex.dev/database/reading-data. + const documents = await ctx.db.query("tablename").collect(); + + // Arguments passed from the client are properties of the args object. + console.log(args.first, args.second); + + // Write arbitrary JavaScript here: filter, aggregate, build derived data, + // remove non-public properties, or create new objects. + return documents; + }, +}); +``` + +Using this query function in a React component looks like: + +```ts +const data = useQuery(api.functions.myQueryFunction, { + first: 10, + second: "hello", +}); +``` + +A mutation function looks like: + +```ts +// functions.js +import { mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export const myMutationFunction = mutation({ + // Validators for arguments. + args: { + first: v.string(), + second: v.string(), + }, + + // Function implementation. + handler: async (ctx, args) => { + // Insert or modify documents in the database here. + // Mutations can also read from the database like queries. + // See https://docs.convex.dev/database/writing-data. + const message = { body: args.first, author: args.second }; + const id = await ctx.db.insert("messages", message); + + // Optionally, return a value from your mutation. + return await ctx.db.get(id); + }, +}); +``` + +Using this mutation function in a React component looks like: + +```ts +const mutation = useMutation(api.functions.myMutationFunction); +function handleButtonPress() { + // fire and forget, the most common way to use mutations + mutation({ first: "Hello!", second: "me" }); + // OR + // use the result once the mutation has completed + mutation({ first: "Hello!", second: "me" }).then((result) => + console.log(result), + ); +} +``` + +Use the Convex CLI to push your functions to a deployment. See everything +the Convex CLI can do by running `npx convex -h` in your project root +directory. To learn more, launch the docs with `npx convex docs`. diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts new file mode 100644 index 0000000..3253a43 --- /dev/null +++ b/convex/_generated/api.d.ts @@ -0,0 +1,219 @@ +/* prettier-ignore-start */ + +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type * as boardSharing from "../boardSharing.js"; +import type * as boards from "../boards.js"; +import type * as notes from "../notes.js"; +import type * as presence from "../presence.js"; +import type * as support from "../support.js"; +import type * as users from "../users.js"; + +import type { + ApiFromModules, + FilterApi, + FunctionReference, +} from "convex/server"; +/** + * A utility for referencing Convex functions in your app's API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +declare const fullApi: ApiFromModules<{ + boardSharing: typeof boardSharing; + boards: typeof boards; + notes: typeof notes; + presence: typeof presence; + support: typeof support; + users: typeof users; +}>; +declare const fullApiWithMounts: typeof fullApi; + +export declare const api: FilterApi< + typeof fullApiWithMounts, + FunctionReference +>; +export declare const internal: FilterApi< + typeof fullApiWithMounts, + FunctionReference +>; + +export declare const components: { + aggregateBoardsByUser: { + btree: { + aggregateBetween: FunctionReference< + "query", + "internal", + { k1?: any; k2?: any }, + { count: number; sum: number } + >; + aggregateBetweenHandler: FunctionReference< + "query", + "internal", + { k1?: any; k2?: any }, + { count: number; sum: number } + >; + atNegativeOffset: FunctionReference< + "query", + "internal", + { k1?: any; k2?: any; offset: number }, + { k: any; s: number; v: any } + >; + atNegativeOffsetHandler: FunctionReference< + "query", + "internal", + { k1?: any; k2?: any; offset: number }, + { k: any; s: number; v: any } + >; + atOffset: FunctionReference< + "query", + "internal", + { k1?: any; k2?: any; offset: number }, + { k: any; s: number; v: any } + >; + atOffsetHandler: FunctionReference< + "query", + "internal", + { k1?: any; k2?: any; offset: number }, + { k: any; s: number; v: any } + >; + count: FunctionReference<"query", "internal", {}, any>; + countHandler: FunctionReference<"query", "internal", {}, any>; + get: FunctionReference< + "query", + "internal", + { key: any }, + null | { k: any; s: number; v: any } + >; + getHandler: FunctionReference< + "query", + "internal", + { key: any }, + null | { k: any; s: number; v: any } + >; + offset: FunctionReference< + "query", + "internal", + { k1?: any; key: any }, + number + >; + offsetHandler: FunctionReference< + "query", + "internal", + { k1?: any; key: any }, + number + >; + offsetUntil: FunctionReference< + "query", + "internal", + { k2?: any; key: any }, + number + >; + offsetUntilHandler: FunctionReference< + "query", + "internal", + { k2?: any; key: any }, + number + >; + paginate: FunctionReference< + "query", + "internal", + { + cursor?: string; + k1?: any; + k2?: any; + limit: number; + order: "asc" | "desc"; + }, + { + cursor: string; + isDone: boolean; + page: Array<{ k: any; s: number; v: any }>; + } + >; + paginateHandler: FunctionReference< + "query", + "internal", + { + cursor?: string; + k1?: any; + k2?: any; + limit: number; + order: "asc" | "desc"; + }, + { + cursor: string; + isDone: boolean; + page: Array<{ k: any; s: number; v: any }>; + } + >; + sum: FunctionReference<"query", "internal", {}, number>; + sumHandler: FunctionReference<"query", "internal", {}, number>; + validate: FunctionReference<"query", "internal", {}, any>; + validateTree: FunctionReference<"query", "internal", {}, any>; + }; + inspect: { + display: FunctionReference<"query", "internal", {}, any>; + dump: FunctionReference<"query", "internal", {}, string>; + inspectNode: FunctionReference< + "query", + "internal", + { node?: string }, + null + >; + }; + public: { + clear: FunctionReference< + "mutation", + "internal", + { maxNodeSize?: number; rootLazy?: boolean }, + null + >; + deleteIfExists: FunctionReference< + "mutation", + "internal", + { key: any }, + any + >; + delete_: FunctionReference<"mutation", "internal", { key: any }, null>; + init: FunctionReference< + "mutation", + "internal", + { maxNodeSize?: number; rootLazy?: boolean }, + null + >; + insert: FunctionReference< + "mutation", + "internal", + { key: any; summand?: number; value: any }, + null + >; + makeRootLazy: FunctionReference<"mutation", "internal", {}, null>; + replace: FunctionReference< + "mutation", + "internal", + { currentKey: any; newKey: any; summand?: number; value: any }, + null + >; + replaceOrInsert: FunctionReference< + "mutation", + "internal", + { currentKey: any; newKey: any; summand?: number; value: any }, + any + >; + }; + }; +}; + +/* prettier-ignore-end */ diff --git a/convex/_generated/api.js b/convex/_generated/api.js new file mode 100644 index 0000000..853a45b --- /dev/null +++ b/convex/_generated/api.js @@ -0,0 +1,27 @@ +/* prettier-ignore-start */ + +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { anyApi, componentsGeneric } from "convex/server"; + +/** + * A utility for referencing Convex functions in your app's API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export const api = anyApi; +export const internal = anyApi; +export const components = componentsGeneric(); + +/* prettier-ignore-end */ diff --git a/convex/_generated/dataModel.d.ts b/convex/_generated/dataModel.d.ts new file mode 100644 index 0000000..91445df --- /dev/null +++ b/convex/_generated/dataModel.d.ts @@ -0,0 +1,64 @@ +/* prettier-ignore-start */ + +/* eslint-disable */ +/** + * Generated data model types. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; +import type { GenericId } from "convex/values"; +import schema from "../schema.js"; + +/** + * The names of all of your Convex tables. + */ +export type TableNames = TableNamesInDataModel; + +/** + * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Doc = DocumentByName< + DataModel, + TableName +>; + +/** + * An identifier for a document in Convex. + * + * Convex documents are uniquely identified by their `Id`, which is accessible + * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). + * + * Documents can be loaded using `db.get(id)` in query and mutation functions. + * + * IDs are just strings at runtime, but this type can be used to distinguish them from other + * strings when type checking. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Id = + GenericId; + +/** + * A type describing your Convex data model. + * + * This type includes information about what tables you have, the type of + * documents stored in those tables, and the indexes defined on them. + * + * This type is used to parameterize methods like `queryGeneric` and + * `mutationGeneric` to make them type-safe. + */ +export type DataModel = DataModelFromSchemaDefinition; + +/* prettier-ignore-end */ diff --git a/convex/_generated/server.d.ts b/convex/_generated/server.d.ts new file mode 100644 index 0000000..788e90c --- /dev/null +++ b/convex/_generated/server.d.ts @@ -0,0 +1,153 @@ +/* prettier-ignore-start */ + +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + ActionBuilder, + AnyComponents, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter, + FunctionReference, +} from "convex/server"; +import type { DataModel } from "./dataModel.js"; + +type GenericCtx = + | GenericActionCtx + | GenericMutationCtx + | GenericQueryCtx; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const query: QueryBuilder; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const internalQuery: QueryBuilder; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const mutation: MutationBuilder; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const internalMutation: MutationBuilder; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export declare const action: ActionBuilder; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export declare const internalAction: ActionBuilder; + +/** + * Define an HTTP action. + * + * This function will be used to respond to HTTP requests received by a Convex + * deployment if the requests matches the path and method where this action + * is routed. Be sure to route your action in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export declare const httpAction: HttpActionBuilder; + +/** + * A set of services for use within Convex query functions. + * + * The query context is passed as the first argument to any Convex query + * function run on the server. + * + * This differs from the {@link MutationCtx} because all of the services are + * read-only. + */ +export type QueryCtx = GenericQueryCtx; + +/** + * A set of services for use within Convex mutation functions. + * + * The mutation context is passed as the first argument to any Convex mutation + * function run on the server. + */ +export type MutationCtx = GenericMutationCtx; + +/** + * A set of services for use within Convex action functions. + * + * The action context is passed as the first argument to any Convex action + * function run on the server. + */ +export type ActionCtx = GenericActionCtx; + +/** + * An interface to read from the database within Convex query functions. + * + * The two entry points are {@link DatabaseReader.get}, which fetches a single + * document by its {@link Id}, or {@link DatabaseReader.query}, which starts + * building a query. + */ +export type DatabaseReader = GenericDatabaseReader; + +/** + * An interface to read from and write to the database within Convex mutation + * functions. + * + * Convex guarantees that all writes within a single mutation are + * executed atomically, so you never have to worry about partial writes leaving + * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) + * for the guarantees Convex provides your functions. + */ +export type DatabaseWriter = GenericDatabaseWriter; + +/* prettier-ignore-end */ diff --git a/convex/_generated/server.js b/convex/_generated/server.js new file mode 100644 index 0000000..1129936 --- /dev/null +++ b/convex/_generated/server.js @@ -0,0 +1,94 @@ +/* prettier-ignore-start */ + +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + actionGeneric, + httpActionGeneric, + queryGeneric, + mutationGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric, + componentsGeneric, +} from "convex/server"; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const query = queryGeneric; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const internalQuery = internalQueryGeneric; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const mutation = mutationGeneric; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const internalMutation = internalMutationGeneric; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export const action = actionGeneric; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export const internalAction = internalActionGeneric; + +/** + * Define a Convex HTTP action. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object + * as its second. + * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. + */ +export const httpAction = httpActionGeneric; + +/* prettier-ignore-end */ diff --git a/convex/auth.config.ts b/convex/auth.config.ts new file mode 100644 index 0000000..58d421a --- /dev/null +++ b/convex/auth.config.ts @@ -0,0 +1,8 @@ +export default { + providers: [ + { + domain: process.env.CLERK_JWT_ISSUER_DOMAIN, + applicationID: "convex", + }, + ] +}; \ No newline at end of file diff --git a/convex/boardSharing.ts b/convex/boardSharing.ts new file mode 100644 index 0000000..1eb2290 --- /dev/null +++ b/convex/boardSharing.ts @@ -0,0 +1,49 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; + +export const shareBoard = mutation({ + args: { boardId: v.id("boards") }, + handler: async (ctx, args) => { + const board = await ctx.db.get(args.boardId); + if (!board) throw new Error("Board not found"); + + if (board.isShared && board.shareCode) { + return board.shareCode; + } + + const shareCode = Math.random().toString(36).substring(2, 8).toUpperCase(); + await ctx.db.patch(args.boardId, { isShared: true, shareCode }); + return shareCode; + }, +}); + + export const getSharedBoardId = query({ + args: { shareCode: v.string() }, + handler: async (ctx, args) => { + const board = await ctx.db + .query("boards") + .withIndex("by_shareCode", (q) => q.eq("shareCode", args.shareCode)) + .first(); + return board && board.isShared ? board._id : null; + }, + }); + + export const toggleBoardSharing = mutation({ + args: { boardId: v.id("boards") }, + handler: async (ctx, args) => { + const board = await ctx.db.get(args.boardId); + if (!board) throw new Error("Board not found"); + + const isShared = !board.isShared; + await ctx.db.patch(args.boardId, { isShared }); + + if (!isShared) { + await ctx.db.patch(args.boardId, { shareCode: undefined }); + } else if (!board.shareCode) { + const shareCode = Math.random().toString(36).substring(2, 8).toUpperCase(); + await ctx.db.patch(args.boardId, { shareCode }); + } + + return isShared; + }, + }); \ No newline at end of file diff --git a/convex/boards.ts b/convex/boards.ts new file mode 100644 index 0000000..301ed46 --- /dev/null +++ b/convex/boards.ts @@ -0,0 +1,258 @@ +import { paginationOptsValidator } from "convex/server"; +import { internalMutation as rawInternalMutation, mutation as rawMutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { TableAggregate } from "@convex-dev/aggregate"; +import { DataModel } from "./_generated/dataModel"; +import { components } from "./_generated/api"; +import { Triggers } from "convex-helpers/server/triggers"; +import { customMutation } from "convex-helpers/server/customFunctions"; + +const aggregateBoardsByUser = new TableAggregate<[string, boolean], DataModel, "boards">( + components.aggregateBoardsByUser, + (doc) => [doc.ownerId, doc.inTrash], +); + +const triggers = new Triggers(); + +triggers.register("boards", aggregateBoardsByUser.trigger()); + +const mutation = customMutation(rawMutation, triggers.customFunctionWrapper()); +const internalMutation = customMutation(rawInternalMutation, triggers.customFunctionWrapper()); + +export const backfillAggregates = internalMutation({ + handler: async (ctx) => { + await aggregateBoardsByUser.clear(ctx); + + for await (const doc of ctx.db.query("boards")) { + await aggregateBoardsByUser.insert(ctx, doc); + } + }, +}); + +export const getUserBoardsCount = query({ + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Not authenticated"); + } + const user = await ctx.db + .query("users") + .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier.split('|')[1])) + .unique(); + if (!user) { + throw new Error("User not found"); + } + return await aggregateBoardsByUser.count(ctx, { prefix: [user._id, false] }); + }, +}); + +export const getUserBoardsTrashCount = query({ + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Not authenticated"); + } + const user = await ctx.db + .query("users") + .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier.split('|')[1])) + .unique(); + if (!user) { + throw new Error("User not found"); + } + return await aggregateBoardsByUser.count(ctx, { prefix: [user._id, true] }); + }, +}); + +export const getBoards = query({ + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Not authenticated"); + } + const user = await ctx.db + .query("users") + .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier.split('|')[1])) + .unique(); + if (!user) { + throw new Error("User not found"); + } + return await ctx.db + .query("boards") + .withIndex("by_owner", (q) => q.eq("ownerId", user._id)) + .collect(); + }, +}); + +export const getBoard = query({ + args: { boardId: v.id("boards") }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Not authenticated"); + } + + const user = await ctx.db + .query("users") + .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier.split('|')[1])) + .unique(); + if (!user) { + throw new Error("User not found"); + } + + const board = await ctx.db.get(args.boardId); + if (!board) { + throw new Error("Board not found"); + } + + if (board.isShared && !board.inTrash || board.ownerId === user._id) { return board; } + else throw new Error("Access denied"); + // if (board.ownerId !== user._id || board.inTrash) { + // if (!board.isShared) { + // throw new Error("Access denied"); + // } + // } + + return board; + }, +}); + +export const getBoardOwner = query({ + args: { boardId: v.id("boards") }, + handler: async (ctx, args) => { + const board = await ctx.db.get(args.boardId); + if (!board) return null; + const owner = await ctx.db.get(board.ownerId); + return owner ? owner.name : null; + }, +}); + +export const createBoard = mutation({ + args: { name: v.string() }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Not authenticated"); + } + const user = await ctx.db + .query("users") + .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier.split('|')[1])) + .unique(); + if (!user) { + throw new Error("User not found"); + } + + const boardId = await ctx.db.insert("boards", { + name: args.name, + ownerId: user._id, + isShared: false, + notesCount: 0, + inTrash: false, + lastModified: Date.now() + }); + return boardId; + }, +}); + +export const updateBoard = mutation({ + args: { boardId: v.id("boards"), name: v.string() }, + handler: async (ctx, args) => { + const { boardId, name } = args; + await ctx.db.patch(boardId, { name, lastModified: Date.now() }); + }, +}); + +export const deleteBoard = mutation({ + args: { boardId: v.id("boards") }, + handler: async (ctx, args) => { + const { boardId } = args; + await ctx.db.patch(boardId, { inTrash: true, isShared: false, shareCode: "" }); + }, +}); + +export const restoreBoard = mutation({ + args: { boardId: v.id("boards") }, + handler: async (ctx, args) => { + const { boardId } = args; + await ctx.db.patch(boardId, { inTrash: false }); + }, +}); + +export const permanentlyDeleteBoard = mutation({ + args: { boardId: v.id("boards") }, + handler: async (ctx, args) => { + const { boardId } = args; + const board = await ctx.db.get(boardId); + if (!board) throw new Error("Board not found"); + + return await ctx.db.delete(boardId); + }, +}); + +export const getLazyBoards = query({ + args: { + paginationOpts: paginationOptsValidator, + searchTerm: v.optional(v.string()), + sortBy: v.union(v.literal("Recent"), v.literal("Oldest"), v.literal("Alphabetical"), v.literal("Most Notes")), + showTrashed: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Not authenticated"); + } + const user = await ctx.db + .query("users") + .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier.split('|')[1])) + .unique(); + if (!user) { + throw new Error("User not found"); + } + + let boardsQuery; + + switch (args.sortBy) { + case "Recent": + boardsQuery = ctx.db.query("boards").withIndex("by_owner_and_modified", (q) => q.eq("ownerId", user._id)).order("desc"); + break; + case "Oldest": + boardsQuery = ctx.db.query("boards").withIndex("by_owner_and_modified", (q) => q.eq("ownerId", user._id)).order("asc"); + break; + case "Alphabetical": + boardsQuery = ctx.db.query("boards").withIndex("by_owner_and_name", (q) => q.eq("ownerId", user._id)).order("asc"); + break; + case "Most Notes": + boardsQuery = ctx.db.query("boards").withIndex("by_owner_and_notes", (q) => q.eq("ownerId", user._id)).order("desc"); + break; + default: + boardsQuery = ctx.db.query("boards").withIndex("by_owner_and_modified", (q) => q.eq("ownerId", user._id)).order("desc"); + } + + if (args.searchTerm) { + boardsQuery = ctx.db + .query("boards") + .withSearchIndex("search_name", (q) => + q.search("name", args.searchTerm!) + .eq("ownerId", user._id) + .eq("inTrash", args.showTrashed ?? false) + ); + } + + if (args.showTrashed !== undefined) { + boardsQuery = boardsQuery.filter((q) => q.eq(q.field("inTrash"), args.showTrashed)); + } + + return await boardsQuery.paginate(args.paginationOpts); + }, +}); + +export const updateNotesCount = internalMutation({ + args: { boardId: v.id("boards"), increment: v.number() }, + handler: async (ctx, args) => { + const { boardId, increment } = args; + const board = await ctx.db.get(boardId); + if (!board) throw new Error("Board not found"); + + const currentCount = board.notesCount || 0; + await ctx.db.patch(boardId, { notesCount: currentCount + increment }); + }, +}); \ No newline at end of file diff --git a/convex/convex.config.ts b/convex/convex.config.ts new file mode 100644 index 0000000..06be69e --- /dev/null +++ b/convex/convex.config.ts @@ -0,0 +1,6 @@ +import { defineApp } from "convex/server"; +import aggregate from "@convex-dev/aggregate/convex.config.js"; + +const app = defineApp(); +app.use(aggregate, { name: "aggregateBoardsByUser" }); +export default app; \ No newline at end of file diff --git a/convex/notes.ts b/convex/notes.ts new file mode 100644 index 0000000..250aaab --- /dev/null +++ b/convex/notes.ts @@ -0,0 +1,74 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { internal } from "./_generated/api"; + +export const createNote = mutation({ + args: { + boardId: v.id("boards"), + content: v.string(), + color: v.string(), + position: v.object({ + x: v.number(), + y: v.number(), + }), + size: v.object({ + width: v.number(), + height: v.number(), + }), + zIndex: v.number(), + }, + handler: async (ctx, args) => { + const noteId = await ctx.db.insert("notes", args); + await ctx.runMutation(internal.boards.updateNotesCount, { boardId: args.boardId, increment: 1 }); + await ctx.db.patch(args.boardId, { lastModified: Date.now() }); + return noteId; + }, +}); + +export const updateNote = mutation({ + args: { + noteId: v.id("notes"), + content: v.optional(v.string()), + color: v.optional(v.string()), + position: v.optional(v.object({ + x: v.number(), + y: v.number(), + })), + size: v.optional(v.object({ + width: v.number(), + height: v.number(), + })), + zIndex: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const { noteId, ...updates } = args; + const note = await ctx.db.get(noteId); + if (!note) throw new Error("Note not found"); + + await ctx.db.patch(noteId, updates); + + await ctx.db.patch(note.boardId, { lastModified: Date.now() }); + }, +}); + +export const deleteNote = mutation({ + args: { noteId: v.id("notes") }, + handler: async (ctx, args) => { + const note = await ctx.db.get(args.noteId); + if (!note) throw new Error("Note not found"); + + await ctx.db.delete(args.noteId); + await ctx.runMutation(internal.boards.updateNotesCount, { boardId: note.boardId, increment: -1 }); + await ctx.db.patch(note.boardId, { lastModified: Date.now() }); + }, +}); + +export const getNotes = query({ + args: { boardId: v.id("boards") }, + handler: async (ctx, args) => { + return await ctx.db + .query("notes") + .withIndex("by_board", (q) => q.eq("boardId", args.boardId)) + .collect(); + }, +}); \ No newline at end of file diff --git a/convex/presence.ts b/convex/presence.ts new file mode 100644 index 0000000..79128c8 --- /dev/null +++ b/convex/presence.ts @@ -0,0 +1,109 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; + +export const updatePresence = mutation({ + args: { + boardId: v.id("boards"), + cursorPosition: v.object({ + x: v.number(), + y: v.number() + }), + isHeartbeat: v.boolean() + }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Not authenticated"); + } + const user = await ctx.db + .query("users") + .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier.split('|')[1])) + .unique(); + if (!user) { + throw new Error("User not found"); + } + + const now = Date.now(); + const existingPresence = await ctx.db + .query("presence") + .withIndex("by_user_and_board", (q) => + q.eq("userId", user._id).eq("boardId", args.boardId) + ) + .first(); + + if (existingPresence) { + await ctx.db.patch(existingPresence._id, { + lastUpdated: now, + cursorPosition: args.isHeartbeat ? existingPresence.cursorPosition : args.cursorPosition + }); + } else { + await ctx.db.insert("presence", { + userId: user._id, + boardId: args.boardId, + lastUpdated: now, + cursorPosition: args.cursorPosition + }); + } + }, +}); + +export const getActiveUsers = query({ + args: { boardId: v.id("boards") }, + handler: async (ctx, args) => { + const thirtySecondsAgo = Date.now() - 30000; + const activePresence = await ctx.db + .query("presence") + .withIndex("by_board_and_lastUpdated", (q) => + q.eq("boardId", args.boardId) + .gte("lastUpdated", thirtySecondsAgo) + ) + .collect(); + + const userIds = [...new Set(activePresence.map((p) => p.userId))]; + const users = await Promise.all( + userIds.map(async (userId) => { + const user = await ctx.db.get(userId); + const presence = activePresence.find(p => p.userId === userId); + if (user && presence) { + return { + _id: user._id, + name: user.name, + profileImageUrl: user.profileImageUrl, + cursorPosition: presence.cursorPosition + }; + } + return null; + }) + ); + + return users.filter((user): user is NonNullable => user !== null); + }, +}); + +export const removePresence = mutation({ + args: { boardId: v.id("boards") }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Not authenticated"); + } + const user = await ctx.db + .query("users") + .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier.split('|')[1])) + .unique(); + if (!user) { + throw new Error("User not found"); + } + + const presence = await ctx.db + .query("presence") + .withIndex("by_user_and_board", (q) => + q.eq("userId", user._id).eq("boardId", args.boardId) + ) + .first(); + + if (presence) { + await ctx.db.delete(presence._id); + } + }, +}); \ No newline at end of file diff --git a/convex/schema.ts b/convex/schema.ts new file mode 100644 index 0000000..4651b18 --- /dev/null +++ b/convex/schema.ts @@ -0,0 +1,65 @@ +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + users: defineTable({ + name: v.string(), + email: v.optional(v.string()), + profileImageUrl: v.optional(v.string()), + tokenIdentifier: v.string(), + plan: v.optional(v.string()), + onBoarding: v.optional(v.boolean()) + }).index("by_token", ["tokenIdentifier"]), + + boards: defineTable({ + name: v.string(), + ownerId: v.id("users"), + isShared: v.boolean(), + shareCode: v.optional(v.string()), + notesCount: v.optional(v.number()), + inTrash: v.boolean(), + _creationTime: v.number(), + lastModified: v.number(), + }) + .index("by_owner", ["ownerId"]) + .index("by_owner_and_modified", ["ownerId", "lastModified"]) + .index("by_owner_and_name", ["ownerId", "name"]) + .index("by_owner_and_notes", ["ownerId", "notesCount"]) + .index("by_shareCode", ["shareCode"]) + .searchIndex("search_name", { + searchField: "name", + filterFields: ["ownerId", "inTrash"] + }), + + notes: defineTable({ + boardId: v.id("boards"), + content: v.string(), + color: v.string(), + position: v.object({ + x: v.number(), + y: v.number(), + }), + size: v.object({ + width: v.number(), + height: v.number(), + }), + zIndex: v.optional(v.number()), + }).index("by_board", ["boardId"]), + + presence: defineTable({ + userId: v.id("users"), + boardId: v.id("boards"), + lastUpdated: v.number(), + cursorPosition: v.object({ + x: v.number(), + y: v.number() + }), + }).index("by_board", ["boardId"]) + .index("by_user_and_board", ["userId", "boardId"]) + .index("by_board_and_lastUpdated", ["boardId", "lastUpdated"]), + + supportRequest: defineTable({ + userId: v.id("users"), + input: v.string() + }) +}); \ No newline at end of file diff --git a/convex/support.ts b/convex/support.ts new file mode 100644 index 0000000..b2ba1d7 --- /dev/null +++ b/convex/support.ts @@ -0,0 +1,23 @@ +import { mutation } from './_generated/server' +import { v } from 'convex/values' + +export const supportRequest = mutation({ + args: { input: v.string() }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Not authenticated"); + } + const user = await ctx.db + .query("users") + .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier.split('|')[1])) + .unique(); + if (!user) { + throw new Error("User not found"); + } + await ctx.db.insert('supportRequest', { + userId: user._id, + input: args.input, + }) + }, +}) \ No newline at end of file diff --git a/convex/tsconfig.json b/convex/tsconfig.json new file mode 100644 index 0000000..6fa874e --- /dev/null +++ b/convex/tsconfig.json @@ -0,0 +1,25 @@ +{ + /* This TypeScript project config describes the environment that + * Convex functions run in and is used to typecheck them. + * You can modify it, but some settings required to use Convex. + */ + "compilerOptions": { + /* These settings are not required by Convex and can be modified. */ + "allowJs": true, + "strict": true, + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + + /* These compiler options are required by Convex */ + "target": "ESNext", + "lib": ["ES2021", "dom"], + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["./**/*"], + "exclude": ["./_generated"] +} diff --git a/convex/users.ts b/convex/users.ts new file mode 100644 index 0000000..5e68784 --- /dev/null +++ b/convex/users.ts @@ -0,0 +1,78 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; + +export const createOrUpdateUser = mutation({ + args: { + tokenIdentifier: v.string(), + name: v.string(), + email: v.optional(v.string()), + profileImageUrl: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Called createOrUpdateUser without authentication present"); + } + + const user = await ctx.db + .query("users") + .withIndex("by_token", (q) => q.eq("tokenIdentifier", args.tokenIdentifier)) + .unique(); + + if (user !== null) { + await ctx.db.patch(user._id, { + name: args.name, + profileImageUrl: args.profileImageUrl, + }); + return user._id; + } + + return await ctx.db.insert("users", { + name: args.name, + email: args.email, + tokenIdentifier: args.tokenIdentifier, + profileImageUrl: args.profileImageUrl, + }); + }, +}); + +export const getCurrentUser = query({ + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("No User Logged in."); + } + const user = await ctx.db + .query("users") + .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier.split('|')[1])) + .unique(); + return user; + } +}) + +export const storePlan = mutation({ + args: { plan: v.string() }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Not authenticated"); + } + + const user = await ctx.db + .query("users") + .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier.split('|')[1])) + .unique(); + + if (!user) { + throw new Error("User not found"); + } + + if(!user.onBoarding){ + await ctx.db.patch(user._id, { plan: args.plan, onBoarding: true }); + return true; + } + else{ + return true; + } + }, +}); \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/index.html b/index.html new file mode 100644 index 0000000..20021a3 --- /dev/null +++ b/index.html @@ -0,0 +1,59 @@ + + + + + + + Ideas that Stick, literally + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..f7c7b19 --- /dev/null +++ b/package.json @@ -0,0 +1,70 @@ +{ + "name": "sticky", + "private": false, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "repository": { + "type": "git", + "url": "https://github.com/hamzasaleem2/sticky" + }, + "keywords": [ + "sticky-notes", + "collaboration", + "organization", + "react", + "typescript", + "convex.dev" + ], + "author": "Hamza Saleem", + "license": "MIT", + "bugs": { + "url": "https://github.com/hamzasaleem2/sticky/issues" + }, + "homepage": "https://github.com/hamzasaleem2/sticky#readme", + "dependencies": { + "@clerk/clerk-react": "^5.11.0", + "@clerk/themes": "^2.1.35", + "@convex-dev/aggregate": "^0.1.13", + "@radix-ui/react-toast": "^1.2.2", + "@types/lodash.debounce": "^4.0.9", + "@types/react-lazy-load-image-component": "^1.6.4", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "convex": "^1.16.3", + "convex-helpers": "^0.1.59", + "lodash.debounce": "^4.0.8", + "lucide-react": "^0.447.0", + "next-themes": "^0.3.0", + "posthog-js": "^1.167.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-lazy-load-image-component": "^1.6.2", + "react-router-dom": "^6.26.2", + "tailwind-merge": "^2.5.2", + "use-image": "^1.1.1" + }, + "devDependencies": { + "@eslint/js": "^9.9.0", + "@types/node": "^22.7.4", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/uuid": "^10.0.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "eslint": "^9.9.0", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", + "typescript": "^5.5.3", + "typescript-eslint": "^8.0.1", + "vite": "^5.4.1" + } +} \ No newline at end of file diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..87b3cf8 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + } + } \ No newline at end of file diff --git a/public/board.png b/public/board.png new file mode 100644 index 0000000..47aff04 Binary files /dev/null and b/public/board.png differ diff --git a/public/duct-tape.png b/public/duct-tape.png new file mode 100644 index 0000000..325c63f Binary files /dev/null and b/public/duct-tape.png differ diff --git a/public/sticky-logo.png b/public/sticky-logo.png new file mode 100644 index 0000000..0a92ea3 Binary files /dev/null and b/public/sticky-logo.png differ diff --git a/public/sticky-sad.png b/public/sticky-sad.png new file mode 100644 index 0000000..da2d5f1 Binary files /dev/null and b/public/sticky-sad.png differ diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..edb72c5 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,102 @@ +import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom'; +import { SignedIn, SignedOut } from '@clerk/clerk-react'; +import FAQSection from "./components/lander/faqs" +import Footer from "./components/lander/footer" +import HeroSection from "./components/lander/hero" +import NavBar from "./components/lander/nav" +import PricingSection from "./components/lander/pricing-beta" +import PrivacyPolicy from "./components/privacy" +import TermsOfConditions from "./components/terms" +import Signin from './auth/signin'; +import { useConvexAuth } from './hooks/useConvexAuth'; +import AuthenticatedApp from './authenticated'; +import Signup from './auth/signup'; +import ErrorBoundary from './components/ErrorBoundary'; +import Onboarding from './authenticated/Onboarding'; + +function App() { + const { isLoaded } = useConvexAuth(); + + if (!isLoaded) { + return; + } + + return ( + + + + } /> + } /> + + +
+ + + +
+