├── curvilinear
├── .gitignore
├── postcss.config.js
├── src
│ ├── assets
│ │ ├── fonts
│ │ │ ├── 27237475-28043385
│ │ │ ├── Inter-UI-Medium.woff
│ │ │ ├── Inter-UI-Medium.woff2
│ │ │ ├── Inter-UI-Regular.woff
│ │ │ ├── Inter-UI-ExtraBold.woff
│ │ │ ├── Inter-UI-Regular.woff2
│ │ │ ├── Inter-UI-SemiBold.woff
│ │ │ ├── Inter-UI-SemiBold.woff2
│ │ │ └── Inter-UI-ExtraBold.woff2
│ │ ├── icons
│ │ │ ├── menu.svg
│ │ │ ├── signal-strong.svg
│ │ │ ├── dots.svg
│ │ │ ├── signal-weak.svg
│ │ │ ├── half-circle.svg
│ │ │ ├── signal-medium.svg
│ │ │ ├── claim.svg
│ │ │ ├── circle.svg
│ │ │ ├── help.svg
│ │ │ ├── label.svg
│ │ │ ├── rounded-claim.svg
│ │ │ ├── assignee.svg
│ │ │ ├── question.svg
│ │ │ ├── parent-issue.svg
│ │ │ ├── done.svg
│ │ │ ├── delete.svg
│ │ │ ├── git-issue.svg
│ │ │ ├── zoom.svg
│ │ │ ├── dupplication.svg
│ │ │ ├── due-date.svg
│ │ │ ├── cancel.svg
│ │ │ ├── inbox.svg
│ │ │ ├── archive.svg
│ │ │ ├── attachment.svg
│ │ │ ├── guide.svg
│ │ │ ├── project.svg
│ │ │ ├── relationship.svg
│ │ │ ├── add.svg
│ │ │ ├── avatar.svg
│ │ │ ├── plus.svg
│ │ │ ├── signal-strong.xsd
│ │ │ ├── search.svg
│ │ │ ├── chat.svg
│ │ │ ├── issue.svg
│ │ │ ├── add-subissue.svg
│ │ │ ├── filter.svg
│ │ │ ├── view.svg
│ │ │ ├── close.svg
│ │ │ ├── circle-dot.svg
│ │ │ └── slack.svg
│ │ └── images
│ │ │ ├── convex.svg
│ │ │ └── logo.svg
│ ├── utils
│ │ ├── date.ts
│ │ ├── notification.tsx
│ │ └── filterState.ts
│ ├── vite-env.d.ts
│ ├── components
│ │ ├── PriorityIcon.tsx
│ │ ├── StatusIcon.tsx
│ │ ├── Portal.tsx
│ │ ├── ItemGroup.tsx
│ │ ├── Select.tsx
│ │ ├── Toggle.tsx
│ │ ├── DebugModal.tsx
│ │ ├── AboutModal.tsx
│ │ ├── contextmenu
│ │ │ ├── StatusMenu.tsx
│ │ │ ├── PriorityMenu.tsx
│ │ │ ├── menu.tsx
│ │ │ └── FilterMenu.tsx
│ │ ├── editor
│ │ │ ├── Editor.tsx
│ │ │ └── EditorMenu.tsx
│ │ ├── Avatar.tsx
│ │ ├── ViewOptionMenu.tsx
│ │ ├── Modal.tsx
│ │ └── ProfileMenu.tsx
│ ├── hooks
│ │ ├── useLockBodyScroll.ts
│ │ ├── useClickOutside.ts
│ │ └── useUser.ts
│ ├── local
│ │ ├── types.ts
│ │ ├── queries.ts
│ │ └── mutations.ts
│ ├── pages
│ │ ├── root.tsx
│ │ ├── List
│ │ │ ├── IssueList.tsx
│ │ │ ├── index.tsx
│ │ │ └── IssueRow.tsx
│ │ └── Issue
│ │ │ ├── DeleteModal.tsx
│ │ │ └── Comments.tsx
│ ├── main.tsx
│ ├── convex.ts
│ ├── index.css
│ ├── App.tsx
│ └── types
│ │ └── types.ts
├── README.md
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
├── convex
│ ├── auth.config.js
│ ├── _generated
│ │ ├── api.js
│ │ ├── api.d.ts
│ │ ├── dataModel.d.ts
│ │ └── server.js
│ ├── comments.ts
│ ├── schema.ts
│ ├── sync
│ │ ├── issues.ts
│ │ ├── comments.ts
│ │ └── schema.ts
│ ├── tsconfig.json
│ ├── README.md
│ └── issues.ts
├── components.json
├── index.html
├── tsconfig.app.json
├── tailwind.config.js
└── package.json
├── pnpm-workspace.yaml
├── local-store
├── shared
│ ├── assert.ts
│ ├── indexKeys.ts
│ ├── queryTokens.ts
│ └── types.ts
├── tsconfig.json
├── package.json
├── browser
│ ├── logger.ts
│ ├── localPersistence.ts
│ ├── localDbWriter.ts
│ ├── network.ts
│ ├── driver.ts
│ ├── core
│ │ ├── optimisticUpdateExecutor.ts
│ │ ├── syncQueryExecutor.ts
│ │ └── protocol.ts
│ ├── localDbReader.ts
│ └── worker
│ │ └── types.ts
├── streamQuery.ts
├── server
│ └── streamQuery.ts
├── react
│ ├── mutationRegistry.ts
│ ├── definitionFactory.ts
│ ├── localDb.ts
│ ├── hooks.ts
│ └── LocalStoreProvider.tsx
└── test
│ └── browser
│ └── localStore.test.ts
├── .gitignore
├── .eslintrc.cjs
└── README.md
/curvilinear/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | .env.local
3 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - 'curvilinear'
3 | - 'local-store'
4 |
--------------------------------------------------------------------------------
/curvilinear/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/fonts/27237475-28043385:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/27237475-28043385
--------------------------------------------------------------------------------
/curvilinear/src/assets/fonts/Inter-UI-Medium.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/Inter-UI-Medium.woff
--------------------------------------------------------------------------------
/curvilinear/src/assets/fonts/Inter-UI-Medium.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/Inter-UI-Medium.woff2
--------------------------------------------------------------------------------
/curvilinear/src/assets/fonts/Inter-UI-Regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/Inter-UI-Regular.woff
--------------------------------------------------------------------------------
/curvilinear/src/assets/fonts/Inter-UI-ExtraBold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/Inter-UI-ExtraBold.woff
--------------------------------------------------------------------------------
/curvilinear/src/assets/fonts/Inter-UI-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/Inter-UI-Regular.woff2
--------------------------------------------------------------------------------
/curvilinear/src/assets/fonts/Inter-UI-SemiBold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/Inter-UI-SemiBold.woff
--------------------------------------------------------------------------------
/curvilinear/src/assets/fonts/Inter-UI-SemiBold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/Inter-UI-SemiBold.woff2
--------------------------------------------------------------------------------
/curvilinear/src/assets/fonts/Inter-UI-ExtraBold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/get-convex/curvilinear/main/curvilinear/src/assets/fonts/Inter-UI-ExtraBold.woff2
--------------------------------------------------------------------------------
/local-store/shared/assert.ts:
--------------------------------------------------------------------------------
1 | export function assert(condition: boolean, message: string): void {
2 | if (!condition) {
3 | throw new Error(message);
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/curvilinear/README.md:
--------------------------------------------------------------------------------
1 | # Convex Linear Clone
2 |
3 | This currently requires the repository to be a sibling of the `convex` monorepo.
4 |
5 | Other than that, `npm run dev` should just work.
6 |
--------------------------------------------------------------------------------
/curvilinear/src/utils/date.ts:
--------------------------------------------------------------------------------
1 | import dayjs from "dayjs";
2 |
3 | export function formatDate(date?: Date): string {
4 | if (!date) return ``;
5 | return dayjs(date).format(`D MMM`);
6 | }
7 |
--------------------------------------------------------------------------------
/curvilinear/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | {
5 | "path": "./tsconfig.app.json"
6 | },
7 | {
8 | "path": "./tsconfig.node.json"
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/curvilinear/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | interface ImportMetaEnv {
4 | readonly VITE_CONVEX_URL: string;
5 | readonly VITE_DISABLE_INDEXED_DB: string | undefined;
6 | // more env variables...
7 | }
8 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/menu.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | !**/glob-import/dir/node_modules
2 | .DS_Store
3 | .idea
4 | *.cpuprofile
5 | *.local
6 | *.log
7 | /.vscode/
8 | /docs/.vitepress/cache
9 | dist
10 | dist-ssr
11 | explorations
12 | node_modules
13 | playground-temp
14 | temp
15 | TODOs.md
16 | .eslintcache
17 | .vercel
18 |
--------------------------------------------------------------------------------
/curvilinear/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "strict": true,
9 | "noEmit": true
10 | },
11 | "include": ["vite.config.ts"]
12 | }
13 |
--------------------------------------------------------------------------------
/curvilinear/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import react from "@vitejs/plugin-react";
3 | import { defineConfig } from "vite";
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [react()],
8 | resolve: {
9 | alias: {
10 | "@": path.resolve(__dirname, "./src"),
11 | },
12 | },
13 | });
14 |
--------------------------------------------------------------------------------
/curvilinear/convex/auth.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | providers: [
3 | {
4 | // Configure CLERK_JWT_ISSUER_DOMAIN on the Convex Dashboard
5 | // See https://docs.convex.dev/auth/clerk#configuring-dev-and-prod-instances
6 | domain: "https://special-mackerel-86.clerk.accounts.dev",
7 | applicationID: "convex",
8 | },
9 | ],
10 | };
11 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/signal-strong.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/dots.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/signal-weak.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/half-circle.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/signal-medium.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/index.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/claim.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/components/PriorityIcon.tsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import { PriorityIcons } from "../types/types";
3 |
4 | interface Props {
5 | priority: string;
6 | className?: string;
7 | }
8 |
9 | export default function PriorityIcon({ priority, className }: Props) {
10 | const classes = classNames(`w-4 h-4`, className);
11 | const Icon = PriorityIcons[priority.toLowerCase()];
12 | return
;
13 | }
14 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/circle.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
11 | CurviLinear
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/curvilinear/src/components/StatusIcon.tsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import { StatusIcons } from "../types/types";
3 |
4 | interface Props {
5 | status: string;
6 | className?: string;
7 | }
8 |
9 | export default function StatusIcon({ status, className }: Props) {
10 | const classes = classNames(`w-3.5 h-3.5 rounded`, className);
11 |
12 | const Icon = StatusIcons[status.toLowerCase()];
13 |
14 | return
;
15 | }
16 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/help.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/convex/_generated/api.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `api` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import { anyApi } from "convex/server";
12 |
13 | /**
14 | * A utility for referencing Convex functions in your app's API.
15 | *
16 | * Usage:
17 | * ```js
18 | * const myFunctionReference = api.myModule.myFunction;
19 | * ```
20 | */
21 | export const api = anyApi;
22 | export const internal = anyApi;
23 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/label.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/rounded-claim.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/assignee.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/hooks/useLockBodyScroll.ts:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect } from "react";
2 |
3 | export default function useLockBodyScroll() {
4 | useLayoutEffect(() => {
5 | // Get original value of body overflow
6 | const originalStyle = window.getComputedStyle(document.body).overflow;
7 | // Prevent scrolling on mount
8 | document.body.style.overflow = `hidden`;
9 | // Re-enable scrolling when component unmounts
10 | return () => {
11 | document.body.style.overflow = originalStyle;
12 | };
13 | }, []); // Empty array ensures effect is only run on mount and unmount
14 | }
15 |
--------------------------------------------------------------------------------
/local-store/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["**/*.ts", "**/*.js", "**/*.tsx"],
3 | "exclude": ["node_modules"],
4 | "compilerOptions": {
5 | "target": "es2020",
6 | "module": "es2020",
7 | "noEmit": true,
8 | "moduleResolution": "bundler",
9 | "isolatedModules": true,
10 | "strict": true,
11 | "esModuleInterop": true,
12 | "skipLibCheck": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "jsx": "react-jsx",
15 | "allowJs": true,
16 | /* allow use of internal Convex APIS */
17 | "customConditions": ["convex-internal-types"]
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/curvilinear/src/local/types.ts:
--------------------------------------------------------------------------------
1 | import { sync as syncSchema } from "../../convex/sync/schema";
2 | import { DefinitionFactory } from "local-store/react/definitionFactory";
3 | import { DataModelFromSchemaDefinition } from "convex/server";
4 | import { LocalDbReader, LocalDbWriter } from "local-store/react/localDb";
5 |
6 | export type SyncDataModel = DataModelFromSchemaDefinition;
7 |
8 | export const factory = new DefinitionFactory(syncSchema);
9 |
10 | export type LocalQueryCtx = { localDb: LocalDbReader };
11 | export type LocalMutationCtx = { localDb: LocalDbWriter };
12 |
--------------------------------------------------------------------------------
/curvilinear/src/components/Portal.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, useState } from "react";
2 | import { useEffect } from "react";
3 | import { createPortal } from "react-dom";
4 |
5 | //Copied from https://github.com/tailwindlabs/headlessui/blob/71730fea1291e572ae3efda16d8644f870d87750/packages/%40headlessui-react/pages/menu/menu-with-popper.tsx#L90
6 | export function Portal(props: { children: ReactNode }) {
7 | const { children } = props;
8 | const [mounted, setMounted] = useState(false);
9 |
10 | useEffect(() => setMounted(true), []);
11 |
12 | if (!mounted) return null;
13 | return createPortal(children, document.body);
14 | }
15 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/question.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/parent-issue.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/convex/comments.ts:
--------------------------------------------------------------------------------
1 | import { v } from "convex/values";
2 | import { mutation } from "./_generated/server";
3 |
4 | export const postComment = mutation({
5 | args: {
6 | id: v.string(),
7 | username: v.string(),
8 | body: v.string(),
9 | issue_id: v.string(),
10 | created_at: v.number(),
11 | },
12 | handler: async (ctx, args) => {
13 | const identity = await ctx.auth.getUserIdentity();
14 | if (!identity) {
15 | throw new Error("Not logged in.");
16 | }
17 | if (identity.name !== args.username) {
18 | throw new Error("Username does not match logged in user.");
19 | }
20 | return ctx.db.insert("comments", args);
21 | },
22 | });
23 |
--------------------------------------------------------------------------------
/curvilinear/convex/schema.ts:
--------------------------------------------------------------------------------
1 | import { defineSchema, defineTable } from "convex/server";
2 | import { v } from "convex/values";
3 |
4 | export default defineSchema({
5 | issues: defineTable({
6 | id: v.string(),
7 | title: v.string(),
8 | description: v.string(),
9 | priority: v.string(),
10 | status: v.string(),
11 | modified: v.number(),
12 | created: v.number(),
13 | username: v.string(),
14 | }).index("by_issue_id", ["id"]),
15 |
16 | comments: defineTable({
17 | id: v.string(),
18 | body: v.string(),
19 | username: v.string(),
20 | issue_id: v.string(),
21 | created_at: v.number(),
22 | }).index("by_issue_id", ["issue_id", "created_at"]),
23 | });
24 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/done.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/local-store/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "local-store",
3 | "version": "0.0.0",
4 | "scripts": {
5 | "build": "tsc -p ."
6 | },
7 | "dependencies": {
8 | "async-channel": "^0.2.0",
9 | "dexie": "~4.0.10",
10 | "zod": "^3.24.0",
11 | "@radix-ui/react-icons": "~1.3.0",
12 | "@radix-ui/react-slot": "^1.0.2",
13 | "convex": "^1.23.0",
14 | "react-dom": "^18.0.0",
15 | "react": "^18.0.0",
16 | "prism-react-renderer": "1.3.5",
17 | "vitest": "^1.6.1"
18 | },
19 | "devDependencies": {
20 | "typescript": "~5.0.3",
21 | "@types/node": "^18.17.0",
22 | "@types/node-fetch": "^2.6.1",
23 | "@types/react": "^18.0.0",
24 | "@types/react-dom": "^18.0.0"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/local-store/browser/logger.ts:
--------------------------------------------------------------------------------
1 | export type LogLevel = "debug" | "info" | "warn" | "error";
2 |
3 | export class Logger {
4 | level: LogLevel = "error";
5 | setLevel(level: LogLevel) {
6 | this.level = level;
7 | }
8 | debug(...args: any[]) {
9 | if (this.level === "debug") {
10 | console.log(`[DEBUG] ${new Date().toISOString()}`, ...args);
11 | }
12 | }
13 | info(...args: any[]) {
14 | if (["debug", "info"].includes(this.level)) {
15 | console.log(`[INFO] ${new Date().toISOString()}`, ...args);
16 | }
17 | }
18 | warn(...args: any[]) {
19 | if (["debug", "info", "warn"].includes(this.level)) {
20 | console.warn(...args);
21 | }
22 | }
23 | error(...args: any[]) {
24 | console.error(...args);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/delete.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/git-issue.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/zoom.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/dupplication.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/due-date.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/cancel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/convex/sync/issues.ts:
--------------------------------------------------------------------------------
1 | import { s, streamQuery } from "./schema";
2 |
3 | const table = s.table("issues", async (ctx, id) => {
4 | const issueId = ctx.db.normalizeId("issues", id);
5 | if (!issueId) {
6 | return null;
7 | }
8 | const issue = await ctx.db.get(issueId);
9 | if (!issue) {
10 | return null;
11 | }
12 | return issue;
13 | });
14 |
15 | export const get = table.get;
16 |
17 | export const by_issue_id = table.index(
18 | "by_issue_id",
19 | async function* (ctx, { key, inclusive, direction }) {
20 | const stream = streamQuery(ctx, {
21 | table: "issues",
22 | index: "by_issue_id",
23 | startIndexKey: [...key],
24 | startInclusive: inclusive,
25 | order: direction,
26 | });
27 | for await (const [issue, _indexKey] of stream) {
28 | yield issue._id;
29 | }
30 | },
31 | );
32 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/inbox.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/convex/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | /* This TypeScript project config describes the environment that
3 | * Convex functions run in and is used to typecheck them.
4 | * You can modify it, but some settings required to use Convex.
5 | */
6 | "compilerOptions": {
7 | /* These settings are not required by Convex and can be modified. */
8 | "allowJs": true,
9 | "strict": true,
10 | "skipLibCheck": true,
11 |
12 | /* These compiler options are required by Convex */
13 | "target": "ESNext",
14 | "lib": ["ES2021", "dom", "ESNext.Array"],
15 | "forceConsistentCasingInFileNames": true,
16 | "allowSyntheticDefaultImports": true,
17 | "module": "ESNext",
18 | "moduleResolution": "Bundler",
19 | "isolatedModules": true,
20 | "noEmit": true
21 | },
22 | "include": ["./**/*"],
23 | "exclude": ["./_generated"]
24 | }
25 |
--------------------------------------------------------------------------------
/local-store/shared/indexKeys.ts:
--------------------------------------------------------------------------------
1 | export function indexFieldsForSyncObject(
2 | syncSchema: any,
3 | table: string,
4 | index: string,
5 | ) {
6 | const indexDefinition: any = syncSchema.tables[table].indexes.find(
7 | (i: any) => i.indexDescriptor === index,
8 | );
9 | if (!indexDefinition) {
10 | throw new Error(`Index ${index} not found for table ${table}`);
11 | }
12 | return indexDefinition.fields;
13 | }
14 |
15 | export function cursorForSyncObject(
16 | syncSchema: any,
17 | table: string,
18 | index: string,
19 | doc: any,
20 | ) {
21 | const fields = indexFieldsForSyncObject(syncSchema, table, index);
22 | // TODO: null is kind of wrong but we can't use undefined because it's not convex-json serializable
23 | return {
24 | kind: "exact" as const,
25 | value: fields.map((field: string) => doc[field] ?? null),
26 | };
27 | }
28 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/archive.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/convex/sync/comments.ts:
--------------------------------------------------------------------------------
1 | import { s, streamQuery } from "./schema";
2 |
3 | const table = s.table("comments", async (ctx, id) => {
4 | const commentId = ctx.db.normalizeId("comments", id);
5 | if (!commentId) {
6 | return null;
7 | }
8 | const comment = await ctx.db.get(commentId);
9 | if (!comment) {
10 | return null;
11 | }
12 | return comment;
13 | });
14 |
15 | export const get = table.get;
16 |
17 | export const by_issue_id = table.index(
18 | "by_issue_id",
19 | async function* (ctx, { key, inclusive, direction }) {
20 | const stream = streamQuery(ctx, {
21 | table: "comments",
22 | index: "by_issue_id",
23 | startIndexKey: [...key],
24 | startInclusive: inclusive,
25 | order: direction,
26 | });
27 | for await (const [comment, _indexKey] of stream) {
28 | yield comment._id;
29 | }
30 | },
31 | );
32 |
--------------------------------------------------------------------------------
/curvilinear/src/pages/root.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from "react-router-dom";
2 | import LeftMenu from "../components/LeftMenu";
3 | import { cssTransition, ToastContainer } from "react-toastify";
4 |
5 | const slideUp = cssTransition({
6 | enter: `animate__animated animate__slideInUp`,
7 | exit: `animate__animated animate__slideOutDown`,
8 | });
9 |
10 | export default function Root() {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/local-store/streamQuery.ts:
--------------------------------------------------------------------------------
1 | import { streamQuery } from "../shared/pagination";
2 |
3 | import {
4 | DataModelFromSchemaDefinition,
5 | GenericQueryCtx,
6 | SchemaDefinition,
7 | TableNamesInDataModel,
8 | } from "convex/server";
9 | import { PageRequest } from "../shared/pagination";
10 |
11 | export const streamQueryForServerSchema = <
12 | ServerSchema extends SchemaDefinition,
13 | >(
14 | schema: ServerSchema,
15 | ) => {
16 | return <
17 | T extends TableNamesInDataModel<
18 | DataModelFromSchemaDefinition
19 | >,
20 | >(
21 | ctx: GenericQueryCtx>,
22 | request: Omit<
23 | PageRequest, T>,
24 | "targetMaxRows" | "absoluteMaxRows" | "schema"
25 | >,
26 | ) => {
27 | return streamQuery(ctx, { ...request, schema });
28 | };
29 | };
30 |
--------------------------------------------------------------------------------
/local-store/server/streamQuery.ts:
--------------------------------------------------------------------------------
1 | import { streamQuery } from "../shared/pagination";
2 |
3 | import {
4 | DataModelFromSchemaDefinition,
5 | GenericQueryCtx,
6 | SchemaDefinition,
7 | TableNamesInDataModel,
8 | } from "convex/server";
9 | import { PageRequest } from "../shared/pagination";
10 |
11 | export const streamQueryForServerSchema = <
12 | ServerSchema extends SchemaDefinition,
13 | >(
14 | schema: ServerSchema,
15 | ) => {
16 | return <
17 | T extends TableNamesInDataModel<
18 | DataModelFromSchemaDefinition
19 | >,
20 | >(
21 | ctx: GenericQueryCtx>,
22 | request: Omit<
23 | PageRequest, T>,
24 | "targetMaxRows" | "absoluteMaxRows" | "schema"
25 | >,
26 | ) => {
27 | return streamQuery(ctx, { ...request, schema });
28 | };
29 | };
30 |
--------------------------------------------------------------------------------
/curvilinear/src/components/ItemGroup.tsx:
--------------------------------------------------------------------------------
1 | import { BsFillCaretDownFill, BsFillCaretRightFill } from "react-icons/bs";
2 | import * as React from "react";
3 | import { useState } from "react";
4 |
5 | interface Props {
6 | title: string;
7 | children: React.ReactNode;
8 | }
9 | function ItemGroup({ title, children }: Props) {
10 | const [showItems, setShowItems] = useState(true);
11 |
12 | const Icon = showItems ? BsFillCaretDownFill : BsFillCaretRightFill;
13 | return (
14 |
15 |
22 | {showItems && children}
23 |
24 | );
25 | }
26 |
27 | export default ItemGroup;
28 |
--------------------------------------------------------------------------------
/curvilinear/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "moduleDetection": "force",
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 |
19 | /* Linting */
20 | "strict": true,
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | "noFallthroughCasesInSwitch": true,
24 |
25 | /* Alias */
26 | "baseUrl": ".",
27 | "paths": {
28 | "@/*": ["./src/*"],
29 | "@cvx/*": ["./convex/*"],
30 | "~/*": ["./*"]
31 | }
32 | },
33 | "include": ["src", "convex"]
34 | }
35 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/images/convex.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/curvilinear/src/hooks/useClickOutside.ts:
--------------------------------------------------------------------------------
1 | import { RefObject, useCallback, useEffect } from "react";
2 |
3 | export const useClickOutside = (
4 | ref: RefObject,
5 | callback: (event: MouseEvent | TouchEvent) => void,
6 | outerRef?: RefObject,
7 | ): void => {
8 | const handleClick = useCallback(
9 | (event: MouseEvent | TouchEvent) => {
10 | if (!event.target || outerRef?.current?.contains(event.target as Node)) {
11 | return;
12 | }
13 | if (ref.current && !ref.current.contains(event.target as Node)) {
14 | callback(event);
15 | }
16 | },
17 | [callback, ref],
18 | );
19 | useEffect(() => {
20 | document.addEventListener(`mousedown`, handleClick);
21 | document.addEventListener(`touchstart`, handleClick);
22 |
23 | return () => {
24 | document.removeEventListener(`mousedown`, handleClick);
25 | document.removeEventListener(`touchstart`, handleClick);
26 | };
27 | });
28 | };
29 |
--------------------------------------------------------------------------------
/curvilinear/src/components/Select.tsx:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import { ReactNode } from "react";
3 |
4 | interface Props {
5 | className?: string;
6 | children: ReactNode;
7 | defaultValue?: string | number | ReadonlyArray;
8 | value?: string | number | ReadonlyArray;
9 | onChange?: (event: React.ChangeEvent) => void;
10 | }
11 | export default function Select(props: Props) {
12 | const { children, defaultValue, className, value, onChange, ...rest } = props;
13 |
14 | const classes = classNames(
15 | `form-select text-xs focus:ring-transparent form-select text-gray-800 h-6 bg-gray-100 rounded pr-4.5 bg-right pl-2 py-0 appearance-none focus:outline-none border-none`,
16 | className,
17 | );
18 | return (
19 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/curvilinear/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { ClerkProvider, useAuth } from "@clerk/clerk-react";
2 | import { ConvexProviderWithClerk } from "convex/react-clerk";
3 | import React from "react";
4 | import ReactDOM from "react-dom/client";
5 | import App from "./App";
6 | import "./index.css";
7 | import { LocalStoreProvider } from "local-store/react/LocalStoreProvider";
8 | import { clerkPubKey, convex, localStore } from "./convex";
9 |
10 | // Create modal root element
11 | const modalRoot = document.createElement("div");
12 | modalRoot.id = "root-modal";
13 | document.body.appendChild(modalRoot);
14 |
15 | ReactDOM.createRoot(document.getElementById("root")!).render(
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | ,
25 | );
26 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/attachment.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/convex/sync/schema.ts:
--------------------------------------------------------------------------------
1 | import { defineSchema, defineTable } from "convex/server";
2 | import { v } from "convex/values";
3 |
4 | import { tableResolverFactory } from "local-store/server/resolvers";
5 | import { streamQueryForServerSchema } from "local-store/server/streamQuery";
6 | import schema from "../schema";
7 |
8 | export const sync = defineSchema({
9 | issues: defineTable({
10 | id: v.string(),
11 | title: v.string(),
12 | description: v.string(),
13 | priority: v.string(),
14 | status: v.string(),
15 | modified: v.number(),
16 | created: v.number(),
17 | username: v.string(),
18 | }).index("by_issue_id", ["id"]),
19 |
20 | comments: defineTable({
21 | id: v.string(),
22 | body: v.string(),
23 | username: v.string(),
24 | issue_id: v.string(),
25 | created_at: v.number(),
26 | }).index("by_issue_id", ["issue_id", "created_at"]),
27 | });
28 |
29 | export const s = tableResolverFactory(sync, schema);
30 | export const streamQuery = streamQueryForServerSchema(schema);
31 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/guide.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/project.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/relationship.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/local-store/react/mutationRegistry.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DataModelFromSchemaDefinition,
3 | FunctionReference,
4 | SchemaDefinition,
5 | getFunctionName,
6 | } from "convex/server";
7 | import { LocalDbWriter } from "./localDb";
8 | import { LocalMutation } from "./definitionFactory";
9 |
10 | export class MutationRegistry> {
11 | constructor(private _syncSchema: SchemaDef) {}
12 |
13 | private mutations: Record<
14 | string,
15 | {
16 | fn: FunctionReference<"mutation", "public">;
17 | optimisticUpdate: (
18 | ctx: {
19 | localDb: LocalDbWriter>;
20 | },
21 | args: any,
22 | ) => void;
23 | serverArgs: (args: any) => any;
24 | }
25 | > = {};
26 | register(mutation: LocalMutation) {
27 | const name = getFunctionName(mutation.fn);
28 | if (this.mutations[name]) {
29 | throw new Error(`Mutation ${name} already registered`);
30 | }
31 | this.mutations[name] = mutation;
32 | return this;
33 | }
34 |
35 | exportToMutationMap() {
36 | return this.mutations;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/add.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/avatar.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/plus.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/signal-strong.xsd:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/curvilinear/src/components/Toggle.tsx:
--------------------------------------------------------------------------------
1 | import classnames from "classnames";
2 |
3 | interface Props {
4 | onChange?: (value: boolean) => void;
5 | className?: string;
6 | value?: boolean;
7 | activeClass?: string;
8 | activeLabelClass?: string;
9 | }
10 | export default function Toggle({
11 | onChange,
12 | className,
13 | value = false,
14 | activeClass = `bg-indigo-600 hover:bg-indigo-700`,
15 | activeLabelClass = `border-indigo-600`,
16 | }: Props) {
17 | const labelClasses = classnames(
18 | `absolute h-3.5 w-3.5 overflow-hidden border-2 transition duration-200 ease-linear rounded-full cursor-pointer bg-white`,
19 | {
20 | "left-0 border-gray-300": !value,
21 | "right-0": value,
22 | [activeLabelClass]: value,
23 | },
24 | );
25 | const classes = classnames(
26 | `group relative rounded-full w-5 h-3.5 transition duration-200 ease-linear`,
27 | {
28 | [activeClass]: value,
29 | "bg-gray-300": !value,
30 | },
31 | className,
32 | );
33 | const onClick = () => {
34 | if (onChange) onChange(!value);
35 | };
36 | return (
37 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/curvilinear/convex/_generated/api.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `api` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import type {
12 | ApiFromModules,
13 | FilterApi,
14 | FunctionReference,
15 | } from "convex/server";
16 | import type * as comments from "../comments.js";
17 | import type * as issues from "../issues.js";
18 | import type * as sync_comments from "../sync/comments.js";
19 | import type * as sync_issues from "../sync/issues.js";
20 | import type * as testing from "../testing.js";
21 |
22 | /**
23 | * A utility for referencing Convex functions in your app's API.
24 | *
25 | * Usage:
26 | * ```js
27 | * const myFunctionReference = api.myModule.myFunction;
28 | * ```
29 | */
30 | declare const fullApi: ApiFromModules<{
31 | comments: typeof comments;
32 | issues: typeof issues;
33 | "sync/comments": typeof sync_comments;
34 | "sync/issues": typeof sync_issues;
35 | testing: typeof testing;
36 | }>;
37 | export declare const api: FilterApi<
38 | typeof fullApi,
39 | FunctionReference
40 | >;
41 | export declare const internal: FilterApi<
42 | typeof fullApi,
43 | FunctionReference
44 | >;
45 |
--------------------------------------------------------------------------------
/curvilinear/src/hooks/useUser.ts:
--------------------------------------------------------------------------------
1 | import { useUser } from "@clerk/clerk-react";
2 | import { useEffect } from "react";
3 |
4 | type User = {
5 | fullName: string;
6 | imageUrl: string;
7 | }
8 |
9 |
10 | export function useCachedUser() {
11 | const { user: clerkUser } = useUser();
12 | let user: User | undefined;
13 | if (clerkUser) {
14 | user = {
15 | fullName: clerkUser.fullName ?? "",
16 | imageUrl: clerkUser.imageUrl ?? "",
17 | };
18 | }
19 | if (!user) {
20 | user = loadCachedUser();
21 | }
22 | useEffect(() => {
23 | const existing = loadCachedUser();
24 | if (clerkUser && (clerkUser.fullName !== existing?.fullName || clerkUser.imageUrl !== existing?.imageUrl)) {
25 | const newUser: User = {
26 | fullName: clerkUser.fullName ?? "",
27 | imageUrl: clerkUser.imageUrl ?? "",
28 | };
29 | localStorage.setItem("cached-user", JSON.stringify(newUser));
30 | }
31 | }, [clerkUser]);
32 |
33 | return user;
34 | }
35 |
36 | function loadCachedUser(): User | undefined {
37 | const existing = localStorage.getItem("cached-user");
38 | return existing ? JSON.parse(existing) : undefined;
39 | }
40 |
--------------------------------------------------------------------------------
/curvilinear/src/pages/List/IssueList.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from "react";
2 | import { FixedSizeList as List, areEqual } from "react-window";
3 | import { memo } from "react";
4 | import AutoSizer from "react-virtualized-auto-sizer";
5 | import IssueRow from "./IssueRow";
6 | import { Issue } from "../../types/types";
7 |
8 | export interface IssueListProps {
9 | issues: Issue[];
10 | }
11 |
12 | function IssueList({ issues }: IssueListProps) {
13 | return (
14 |
15 |
16 | {({ height, width }) => (
17 |
24 | {VirtualIssueRow}
25 |
26 | )}
27 |
28 |
29 | );
30 | }
31 |
32 | const VirtualIssueRow = memo(
33 | ({
34 | data: issues,
35 | index,
36 | style,
37 | }: {
38 | data: Issue[];
39 | index: number;
40 | style: CSSProperties;
41 | }) => {
42 | const issue = issues[index];
43 | return ;
44 | },
45 | areEqual,
46 | );
47 |
48 | export default IssueList;
49 |
--------------------------------------------------------------------------------
/curvilinear/src/convex.ts:
--------------------------------------------------------------------------------
1 | import { sync as syncSchema } from "../convex/sync/schema";
2 | import { ConvexReactClient } from "convex/react";
3 | import { MutationRegistry } from "local-store/react/mutationRegistry";
4 | import {
5 | createIssue,
6 | changeStatus,
7 | changePriority,
8 | changeTitle,
9 | changeDescription,
10 | deleteIssue,
11 | postComment,
12 | } from "./local/mutations";
13 | import { createLocalStoreClient } from "local-store/react/LocalStoreProvider";
14 |
15 | export const clerkPubKey =
16 | "pk_test_c3BlY2lhbC1tYWNrZXJlbC04Ni5jbGVyay5hY2NvdW50cy5kZXYk";
17 | export const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
18 |
19 | const mutationRegistry = new MutationRegistry(syncSchema);
20 | mutationRegistry
21 | .register(createIssue)
22 | .register(changeStatus)
23 | .register(changePriority)
24 | .register(changeTitle)
25 | .register(changeDescription)
26 | .register(deleteIssue)
27 | .register(postComment);
28 |
29 | export const localStore = createLocalStoreClient({
30 | syncSchema,
31 | mutationRegistry,
32 | convexClient: convex,
33 | convexUrl: import.meta.env.VITE_CONVEX_URL,
34 | persistenceKey:
35 | import.meta.env.VITE_DISABLE_INDEXED_DB !== undefined
36 | ? null
37 | : "curvilinear",
38 | });
39 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/search.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/chat.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/pages/Issue/DeleteModal.tsx:
--------------------------------------------------------------------------------
1 | import Modal from "../../components/Modal";
2 |
3 | interface Props {
4 | isOpen: boolean;
5 | setIsOpen: (isOpen: boolean) => void;
6 | onDismiss?: () => void;
7 | deleteIssue: () => void;
8 | }
9 |
10 | export default function AboutModal({
11 | isOpen,
12 | setIsOpen,
13 | onDismiss,
14 | deleteIssue,
15 | }: Props) {
16 | const handleDelete = () => {
17 | setIsOpen(false);
18 | if (onDismiss) onDismiss();
19 | deleteIssue();
20 | };
21 |
22 | return (
23 |
24 |
25 | Are you sure you want to delete this issue?
26 |
27 |
28 |
37 |
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/curvilinear/src/pages/List/index.tsx:
--------------------------------------------------------------------------------
1 | import TopFilter from "../../components/TopFilter";
2 | import IssueList from "./IssueList";
3 | import { useFilterState } from "../../utils/filterState";
4 | import { Issue } from "../../types/types";
5 | import { loadAllIssues } from "@/local/queries";
6 | import { useLocalQuery } from "local-store/react/hooks";
7 |
8 | function List({ showSearch = false }) {
9 | const [filterState] = useFilterState();
10 | const issues: Issue[] = useLocalQuery(loadAllIssues, {}) ?? [];
11 | const filteredIssues = issues.filter((issue) => {
12 | const tests = [true];
13 | if (filterState.priority && filterState.priority.length > 0) {
14 | tests.push(filterState.priority.includes(issue.priority));
15 | }
16 | if (filterState.status && filterState.status.length > 0) {
17 | tests.push(filterState.status.includes(issue.status));
18 | }
19 |
20 | if (typeof filterState.query !== `undefined`) {
21 | tests.push(issue.title.includes(filterState.query));
22 | }
23 |
24 | // Return true only if all tests are true
25 | return tests.every((test) => test);
26 | });
27 |
28 | return (
29 |
30 |
31 |
32 |
33 | );
34 | }
35 |
36 | export default List;
37 |
--------------------------------------------------------------------------------
/curvilinear/src/components/DebugModal.tsx:
--------------------------------------------------------------------------------
1 | import Modal from "./Modal";
2 |
3 | interface Props {
4 | isOpen: boolean;
5 | onDismiss?: () => void;
6 | }
7 |
8 | export default function DebugModal({ isOpen, onDismiss }: Props) {
9 | return (
10 |
11 |
12 |
13 | This is an example of a team collaboration app such as{` `}
14 |
15 | Linear
16 |
17 | {` `}
18 | built using{` `}
19 |
20 | Convex
21 |
22 | {` `}.
23 |
24 |
25 | This example is built on top of ElectricSQL's fork of the excellent
26 | clone of the Linear UI built by{` `}
27 |
28 | Tuan Nguyen
29 |
30 | .
31 |
32 |
33 | We have replaced the canned data with a stack running{` `}
34 |
35 | Convex
36 |
37 | .
38 |
39 |
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/curvilinear/src/components/AboutModal.tsx:
--------------------------------------------------------------------------------
1 | import Modal from "./Modal";
2 |
3 | interface Props {
4 | isOpen: boolean;
5 | onDismiss?: () => void;
6 | }
7 |
8 | export default function AboutModal({ isOpen, onDismiss }: Props) {
9 | return (
10 |
11 |
12 |
13 | This is an example of a team collaboration app such as{` `}
14 |
15 | Linear
16 |
17 | {` `}
18 | built using{` `}
19 |
20 | Convex
21 |
22 | {` `}.
23 |
24 |
25 | This example is built on top of ElectricSQL's fork of the excellent
26 | clone of the Linear UI built by{` `}
27 |
28 | Tuan Nguyen
29 |
30 | .
31 |
32 |
33 | We have replaced the canned data with a stack running{` `}
34 |
35 | Convex
36 |
37 | .
38 |
39 |
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/issue.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/add-subissue.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/local-store/browser/localPersistence.ts:
--------------------------------------------------------------------------------
1 | import { MutationInfo, PersistId } from "../shared/types";
2 | import { CorePersistenceRequest, Page } from "./core/protocol";
3 |
4 | export interface LocalPersistence {
5 | addListener(listener: (request: CorePersistenceRequest) => void): void;
6 | persistMutation(persistId: PersistId, mutationInfo: MutationInfo): void;
7 | persistPages(persistId: PersistId, pages: Page[]): void;
8 | }
9 |
10 | export class NoopLocalPersistence implements LocalPersistence {
11 | private listeners: Set<(request: CorePersistenceRequest) => void> = new Set();
12 |
13 | addListener(listener: (request: CorePersistenceRequest) => void) {
14 | this.listeners.add(listener);
15 | setTimeout(() => {
16 | listener({
17 | requestor: "LocalPersistence",
18 | kind: "ingestFromLocalPersistence",
19 | pages: [],
20 | serverTs: 0,
21 | });
22 | }, 0);
23 | }
24 |
25 | persistMutation(persistId: PersistId, _mutationInfo: MutationInfo) {
26 | setTimeout(() => {
27 | for (const listener of this.listeners) {
28 | listener({
29 | requestor: "LocalPersistence",
30 | kind: "localPersistComplete",
31 | persistId,
32 | });
33 | }
34 | }, 0);
35 | }
36 |
37 | persistPages(persistId: PersistId, _pages: Page[]) {
38 | setTimeout(() => {
39 | for (const listener of this.listeners) {
40 | listener({
41 | requestor: "LocalPersistence",
42 | kind: "localPersistComplete",
43 | persistId,
44 | });
45 | }
46 | }, 0);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/filter.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/local/queries.ts:
--------------------------------------------------------------------------------
1 | import { factory } from "./types";
2 |
3 | export const preload = factory.defineLocalQuery((ctx) => {
4 | const issues = ctx.localDb.query("issues").withIndex("by_issue_id").collect();
5 | const allComments = [];
6 | for (const issue of issues) {
7 | const comments = ctx.localDb
8 | .query("comments")
9 | .withIndex("by_issue_id", (q: any) => q.eq("issue_id", issue.id))
10 | .collect();
11 | allComments.push(...comments);
12 | }
13 | let length = 0;
14 | for (const issue of issues) {
15 | length += JSON.stringify(issue).length;
16 | }
17 | for (const comment of allComments) {
18 | length += JSON.stringify(comment).length;
19 | }
20 | console.log(
21 | `Preloaded ${issues.length} issues and ${allComments.length} comments (${(length / 1024).toFixed(2)}KB).`,
22 | );
23 | return [];
24 | }, "preload");
25 |
26 | export const loadAllIssues = factory.defineLocalQuery((ctx) => {
27 | return ctx.localDb.query("issues").withIndex("by_issue_id").collect();
28 | }, "loadAllIssues");
29 |
30 | export const getIssueById = factory.defineLocalQuery(
31 | (ctx, args: { id: string }) => {
32 | // XXX: `.eq()` type expectes undefined.
33 | return ctx.localDb
34 | .query("issues")
35 | .withIndex("by_issue_id", (q) => q.eq("id", args.id as any))
36 | .unique();
37 | },
38 | "getIssueById",
39 | );
40 |
41 | export const loadComments = factory.defineLocalQuery(
42 | (ctx, args: { issue_id: string }) => {
43 | return ctx.localDb
44 | .query("comments")
45 | .withIndex("by_issue_id", (q: any) => q.eq("issue_id", args.issue_id))
46 | .collect();
47 | },
48 | "loadComments",
49 | );
50 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/view.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 | body {
5 | font-size: 12px;
6 | @apply font-medium text-gray-600;
7 | }
8 |
9 | @font-face {
10 | font-family: "Inter UI";
11 | font-style: normal;
12 | font-weight: 400;
13 | font-display: swap;
14 | src:
15 | url("assets/fonts/Inter-UI-Regular.woff2") format("woff2"),
16 | url("assets/fonts/Inter-UI-Regular.woff") format("woff");
17 | }
18 |
19 | @font-face {
20 | font-family: "Inter UI";
21 | font-style: normal;
22 | font-weight: 500;
23 | font-display: swap;
24 | src:
25 | url("assets/fonts/Inter-UI-Medium.woff2") format("woff2"),
26 | url("assets/fonts/Inter-UI-Medium.woff") format("woff");
27 | }
28 |
29 | @font-face {
30 | font-family: "Inter UI";
31 | font-style: normal;
32 | font-weight: 600;
33 | font-display: swap;
34 | src:
35 | url("assets/fonts/Inter-UI-SemiBold.woff2") format("woff2"),
36 | url("assets/fonts/Inter-UI-SemiBold.woff") format("woff");
37 | }
38 |
39 | @font-face {
40 | font-family: "Inter UI";
41 | font-style: normal;
42 | font-weight: 800;
43 | font-display: swap;
44 | src:
45 | url("assets/fonts/Inter-UI-ExtraBold.woff2") format("woff2"),
46 | url("assets/fonts/Inter-UI-ExtraBold.woff") format("woff");
47 | }
48 |
49 | .modal {
50 | max-width: calc(100vw - 32px);
51 | max-height: calc(100vh - 32px);
52 | }
53 |
54 | .editor ul {
55 | list-style-type: circle;
56 | }
57 | .editor ol {
58 | list-style-type: decimal;
59 | }
60 |
61 | #root,
62 | body,
63 | html {
64 | height: 100%;
65 | }
66 |
67 | .tiptap p.is-editor-empty:first-child::before {
68 | color: #adb5bd;
69 | content: attr(data-placeholder);
70 | float: left;
71 | height: 0;
72 | pointer-events: none;
73 | }
74 |
--------------------------------------------------------------------------------
/curvilinear/src/components/contextmenu/StatusMenu.tsx:
--------------------------------------------------------------------------------
1 | import { Portal } from "../Portal";
2 | import { ReactNode, useState } from "react";
3 | import { ContextMenuTrigger } from "@firefox-devtools/react-contextmenu";
4 | import { StatusOptions } from "../../types/types";
5 | import { Menu } from "./menu";
6 |
7 | interface Props {
8 | id: string;
9 | button: ReactNode;
10 | className?: string;
11 | onSelect?: (item: unknown) => void;
12 | }
13 | export default function StatusMenu({ id, button, className, onSelect }: Props) {
14 | const [keyword, setKeyword] = useState(``);
15 | const handleSelect = (status: string) => {
16 | if (onSelect) onSelect(status);
17 | };
18 |
19 | let statuses = StatusOptions;
20 | if (keyword !== ``) {
21 | const normalizedKeyword = keyword.toLowerCase().trim();
22 | statuses = statuses.filter(
23 | ([_icon, _id, l]) => l.toLowerCase().indexOf(normalizedKeyword) !== -1,
24 | );
25 | }
26 |
27 | const options = statuses.map(([svg, id, label]) => {
28 | return (
29 | handleSelect(id)}>
30 |
31 | {label}
32 |
33 | );
34 | });
35 |
36 | return (
37 | <>
38 |
39 | {button}
40 |
41 |
42 |
43 |
53 |
54 | >
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true, node: true },
4 | extends: [
5 | "eslint:recommended",
6 | "plugin:@typescript-eslint/recommended-type-checked",
7 | "plugin:react-hooks/recommended",
8 | ],
9 | ignorePatterns: [
10 | "dist",
11 | "convex/_generated",
12 | ".eslintrc.cjs",
13 | "tailwind.config.js",
14 | // There are currently ESLint errors in shadcn/ui
15 | "src/components/ui",
16 | ],
17 | parser: "@typescript-eslint/parser",
18 | parserOptions: {
19 | project: "./tsconfig.app.json",
20 | tsconfigRootDir: __dirname,
21 | },
22 | plugins: ["react-refresh"],
23 | rules: {
24 | "react-refresh/only-export-components": [
25 | "warn",
26 | { allowConstantExport: true },
27 | ],
28 |
29 | // All of these overrides ease getting into
30 | // TypeScript, and can be removed for stricter
31 | // linting down the line.
32 |
33 | // Only warn on unused variables, and ignore variables starting with `_`
34 | "@typescript-eslint/no-unused-vars": [
35 | "warn",
36 | { varsIgnorePattern: "^_", argsIgnorePattern: "^_" },
37 | ],
38 |
39 | // Allow escaping the compiler
40 | "@typescript-eslint/ban-ts-comment": "error",
41 |
42 | // Allow explicit `any`s
43 | "@typescript-eslint/no-explicit-any": "off",
44 |
45 | // START: Allow implicit `any`s
46 | "@typescript-eslint/no-unsafe-argument": "off",
47 | "@typescript-eslint/no-unsafe-assignment": "off",
48 | "@typescript-eslint/no-unsafe-call": "off",
49 | "@typescript-eslint/no-unsafe-member-access": "off",
50 | "@typescript-eslint/no-unsafe-return": "off",
51 | // END: Allow implicit `any`s
52 |
53 | // Allow async functions without await
54 | // for consistency (esp. Convex `handler`s)
55 | "@typescript-eslint/require-await": "off",
56 | },
57 | };
58 |
--------------------------------------------------------------------------------
/curvilinear/convex/_generated/dataModel.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated data model types.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import type {
12 | DataModelFromSchemaDefinition,
13 | DocumentByName,
14 | TableNamesInDataModel,
15 | SystemTableNames,
16 | } from "convex/server";
17 | import type { GenericId } from "convex/values";
18 | import schema from "../schema.js";
19 |
20 | /**
21 | * The names of all of your Convex tables.
22 | */
23 | export type TableNames = TableNamesInDataModel;
24 |
25 | /**
26 | * The type of a document stored in Convex.
27 | *
28 | * @typeParam TableName - A string literal type of the table name (like "users").
29 | */
30 | export type Doc = DocumentByName<
31 | DataModel,
32 | TableName
33 | >;
34 |
35 | /**
36 | * An identifier for a document in Convex.
37 | *
38 | * Convex documents are uniquely identified by their `Id`, which is accessible
39 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
40 | *
41 | * Documents can be loaded using `db.get(id)` in query and mutation functions.
42 | *
43 | * IDs are just strings at runtime, but this type can be used to distinguish them from other
44 | * strings when type checking.
45 | *
46 | * @typeParam TableName - A string literal type of the table name (like "users").
47 | */
48 | export type Id =
49 | GenericId;
50 |
51 | /**
52 | * A type describing your Convex data model.
53 | *
54 | * This type includes information about what tables you have, the type of
55 | * documents stored in those tables, and the indexes defined on them.
56 | *
57 | * This type is used to parameterize methods like `queryGeneric` and
58 | * `mutationGeneric` to make them type-safe.
59 | */
60 | export type DataModel = DataModelFromSchemaDefinition;
61 |
--------------------------------------------------------------------------------
/curvilinear/src/components/contextmenu/PriorityMenu.tsx:
--------------------------------------------------------------------------------
1 | import { Portal } from "../Portal";
2 | import { ReactNode, useState } from "react";
3 | import { ContextMenuTrigger } from "@firefox-devtools/react-contextmenu";
4 | import { Menu } from "./menu";
5 | import { PriorityOptions } from "../../types/types";
6 |
7 | interface Props {
8 | id: string;
9 | button: ReactNode;
10 | filterKeyword?: boolean;
11 | className?: string;
12 | onSelect?: (item: string) => void;
13 | }
14 |
15 | function PriorityMenu({
16 | id,
17 | button,
18 | filterKeyword = false,
19 | className,
20 | onSelect,
21 | }: Props) {
22 | const [keyword, setKeyword] = useState(``);
23 |
24 | const handleSelect = (priority: string) => {
25 | setKeyword(``);
26 | if (onSelect) onSelect(priority);
27 | };
28 | let statusOpts = PriorityOptions;
29 | if (keyword !== ``) {
30 | const normalizedKeyword = keyword.toLowerCase().trim();
31 | statusOpts = statusOpts.filter(
32 | ([_Icon, _priority, label]) =>
33 | (label as string).toLowerCase().indexOf(normalizedKeyword) !== -1,
34 | );
35 | }
36 |
37 | const options = statusOpts.map(([svg, priority, label], idx) => {
38 | return (
39 | handleSelect(priority as string)}
42 | >
43 |
{label}
44 |
45 | );
46 | });
47 |
48 | return (
49 | <>
50 |
51 | {button}
52 |
53 |
54 |
55 |
65 |
66 | >
67 | );
68 | }
69 |
70 | export default PriorityMenu;
71 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/close.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/local-store/shared/queryTokens.ts:
--------------------------------------------------------------------------------
1 | import { FunctionReference, getFunctionName } from "convex/server";
2 | import { ConvexSubscriptionId, IndexName, TableName } from "./types";
3 | import { convexToJson } from "convex/values";
4 | import { Value } from "convex/values";
5 |
6 | export function createQueryToken(
7 | path: FunctionReference,
8 | args: any,
9 | ): ConvexSubscriptionId {
10 | return serializePathAndArgs(
11 | getFunctionName(path),
12 | args,
13 | ) as ConvexSubscriptionId;
14 | }
15 |
16 | export function canonicalizeUdfPath(udfPath: string): string {
17 | const pieces = udfPath.split(":");
18 | let moduleName: string;
19 | let functionName: string;
20 | if (pieces.length === 1) {
21 | moduleName = pieces[0];
22 | functionName = "default";
23 | } else {
24 | moduleName = pieces.slice(0, pieces.length - 1).join(":");
25 | functionName = pieces[pieces.length - 1];
26 | }
27 | if (moduleName.endsWith(".js")) {
28 | moduleName = moduleName.slice(0, -3);
29 | }
30 | return `${moduleName}:${functionName}`;
31 | }
32 |
33 | /**
34 | * A string representing the name and arguments of a query.
35 | *
36 | * This is used by the {@link BaseConvexClient}.
37 | *
38 | * @public
39 | */
40 | export type QueryToken = string;
41 |
42 | export function serializePathAndArgs(
43 | udfPath: string,
44 | args: Record,
45 | ): QueryToken {
46 | return JSON.stringify({
47 | udfPath: canonicalizeUdfPath(udfPath),
48 | args: convexToJson(args),
49 | });
50 | }
51 |
52 | export const parseIndexNameAndTableName = (
53 | token: QueryToken,
54 | ): {
55 | indexName: IndexName;
56 | tableName: TableName;
57 | } | null => {
58 | const { udfPath } = JSON.parse(token);
59 | const [filePath, indexName] = udfPath.split(":");
60 | const filePathParts = filePath.split("/");
61 | if (filePathParts.length < 2) {
62 | return null;
63 | }
64 | const tableName = filePathParts.at(-1);
65 | const syncPart = filePathParts.at(-2);
66 | if (syncPart !== "sync") {
67 | return null;
68 | }
69 | return { indexName, tableName };
70 | };
71 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/circle-dot.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/curvilinear/src/components/editor/Editor.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useEditor,
3 | EditorContent,
4 | BubbleMenu,
5 | type Extensions,
6 | } from "@tiptap/react";
7 | import StarterKit from "@tiptap/starter-kit";
8 | import Placeholder from "@tiptap/extension-placeholder";
9 | import Table from "@tiptap/extension-table";
10 | import TableCell from "@tiptap/extension-table-cell";
11 | import TableHeader from "@tiptap/extension-table-header";
12 | import TableRow from "@tiptap/extension-table-row";
13 | import { Markdown } from "tiptap-markdown";
14 | import EditorMenu from "./EditorMenu";
15 | import { useEffect, useRef } from "react";
16 |
17 | interface EditorProps {
18 | value: string;
19 | onChange: (value: string) => void;
20 | className?: string;
21 | placeholder?: string;
22 | }
23 |
24 | const Editor = ({
25 | value,
26 | onChange,
27 | className = ``,
28 | placeholder,
29 | }: EditorProps) => {
30 | const editorProps = {
31 | attributes: {
32 | class: className,
33 | },
34 | };
35 | const markdownValue = useRef(null);
36 |
37 | const extensions: Extensions = [
38 | StarterKit,
39 | Markdown,
40 | Table,
41 | TableRow,
42 | TableHeader,
43 | TableCell,
44 | ];
45 |
46 | const editor = useEditor({
47 | extensions,
48 | editorProps,
49 | content: value || undefined,
50 | onUpdate: ({ editor }) => {
51 | markdownValue.current = editor.storage.markdown.getMarkdown();
52 | onChange(markdownValue.current || ``);
53 | },
54 | });
55 |
56 | useEffect(() => {
57 | if (editor && markdownValue.current !== value) {
58 | editor.commands.setContent(value);
59 | }
60 | }, [value]);
61 |
62 | if (placeholder) {
63 | extensions.push(
64 | Placeholder.configure({
65 | placeholder,
66 | }),
67 | );
68 | }
69 |
70 | return (
71 | <>
72 |
73 | {editor && (
74 |
75 |
76 |
77 | )}
78 | >
79 | );
80 | };
81 |
82 | export default Editor;
83 |
--------------------------------------------------------------------------------
/local-store/browser/localDbWriter.ts:
--------------------------------------------------------------------------------
1 | import { GenericDocument } from "convex/server";
2 | import { IndexRangeRequest } from "../shared/types";
3 |
4 | import { IndexRangeBounds, TableName } from "../shared/types";
5 | import { GenericId } from "convex/values";
6 | import { LocalDbReaderImpl } from "./localDbReader";
7 |
8 | export class LocalDbWriterImpl extends LocalDbReaderImpl {
9 | public debugIndexRanges: Map<
10 | string,
11 | {
12 | table: string;
13 | index: string;
14 | indexRangeBounds: IndexRangeBounds;
15 | order: "asc" | "desc";
16 | limit: number;
17 | }
18 | > = new Map();
19 | private recordWrite: (
20 | tableName: TableName,
21 | id: GenericId,
22 | doc: GenericDocument | null,
23 | ) => void;
24 | constructor(
25 | syncSchema: any,
26 | requestRange: (rangeRequest: IndexRangeRequest) => Array,
27 | loadObject: (
28 | table: TableName,
29 | id: GenericId,
30 | ) => GenericDocument | null,
31 | recordWrite: (
32 | tableName: TableName,
33 | id: GenericId,
34 | doc: GenericDocument | null,
35 | ) => void,
36 | ) {
37 | super(syncSchema, requestRange, loadObject);
38 | this.recordWrite = recordWrite;
39 | }
40 |
41 | insert(
42 | tableName: TableName,
43 | id: GenericId,
44 | doc: GenericDocument | null,
45 | ) {
46 | this.recordWrite(tableName, id, {
47 | ...doc,
48 | _creationTime: Date.now(),
49 | _id: id,
50 | });
51 | }
52 |
53 | delete(tableName: TableName, id: GenericId) {
54 | this.recordWrite(tableName, id, null);
55 | }
56 |
57 | replace(tableName: TableName, id: GenericId, doc: GenericDocument) {
58 | this.recordWrite(tableName, id, doc);
59 | }
60 |
61 | patch(tableName: TableName, id: GenericId, update: GenericDocument) {
62 | const existing = this.get(tableName, id);
63 | if (existing === null) {
64 | throw new Error("Object not found");
65 | }
66 | this.recordWrite(tableName, id, {
67 | ...existing,
68 | ...update,
69 | });
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/local-store/react/definitionFactory.ts:
--------------------------------------------------------------------------------
1 | import { SchemaDefinition } from "convex/server";
2 | import { DefaultFunctionArgs, FunctionReference } from "convex/server";
3 | import { LocalDbReader, LocalDbWriter } from "./localDb";
4 | import { DataModelFromSchemaDefinition } from "convex/server";
5 | import { Value } from "convex/values";
6 |
7 | export interface LocalMutation<
8 | ServerArgs extends DefaultFunctionArgs,
9 | OptimisticUpdateArgs extends DefaultFunctionArgs = ServerArgs,
10 | > {
11 | fn: FunctionReference<"mutation", "public", ServerArgs>;
12 | optimisticUpdate: (
13 | ctx: { localDb: LocalDbWriter },
14 | args: OptimisticUpdateArgs,
15 | ) => void;
16 | serverArgs: (args: OptimisticUpdateArgs) => ServerArgs;
17 | __localMutation: true;
18 | }
19 |
20 | export interface LocalQuery {
21 | handler: (ctx: { localDb: LocalDbReader }, args: Args) => T;
22 | debugName?: string;
23 | __localQuery: true;
24 | }
25 |
26 | export class DefinitionFactory> {
27 | constructor(private syncSchema: SchemaDef) {}
28 |
29 | defineLocalMutation<
30 | ServerArgs extends DefaultFunctionArgs,
31 | OptimisticUpdateArgs extends DefaultFunctionArgs = ServerArgs,
32 | >(
33 | fn: FunctionReference<"mutation", "public", ServerArgs>,
34 | optimisticUpdate: (
35 | ctx: { localDb: LocalDbWriter> },
36 | args: OptimisticUpdateArgs,
37 | ) => void,
38 | serverArgs?: (args: OptimisticUpdateArgs) => ServerArgs,
39 | ): LocalMutation {
40 | return {
41 | fn,
42 | optimisticUpdate,
43 | serverArgs:
44 | serverArgs ??
45 | ((args: OptimisticUpdateArgs) => args as unknown as ServerArgs),
46 | __localMutation: true,
47 | };
48 | }
49 |
50 | defineLocalQuery(
51 | f: (
52 | ctx: { localDb: LocalDbReader> },
53 | args: Args,
54 | ) => T,
55 | debugName?: string,
56 | ): LocalQuery {
57 | return {
58 | handler: f,
59 | debugName,
60 | __localQuery: true,
61 | };
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/curvilinear/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
4 | theme: {
5 | container: {
6 | center: true,
7 | padding: "2rem",
8 | screens: {
9 | "2xl": "1400px",
10 | },
11 | },
12 | extend: {
13 | colors: {
14 | border: "hsl(var(--border))",
15 | input: "hsl(var(--input))",
16 | ring: "hsl(var(--ring))",
17 | background: "hsl(var(--background))",
18 | foreground: "hsl(var(--foreground))",
19 | primary: {
20 | DEFAULT: "hsl(var(--primary))",
21 | foreground: "hsl(var(--primary-foreground))",
22 | },
23 | secondary: {
24 | DEFAULT: "hsl(var(--secondary))",
25 | foreground: "hsl(var(--secondary-foreground))",
26 | },
27 | destructive: {
28 | DEFAULT: "hsl(var(--destructive))",
29 | foreground: "hsl(var(--destructive-foreground))",
30 | },
31 | muted: {
32 | DEFAULT: "hsl(var(--muted))",
33 | foreground: "hsl(var(--muted-foreground))",
34 | },
35 | accent: {
36 | DEFAULT: "hsl(var(--accent))",
37 | foreground: "hsl(var(--accent-foreground))",
38 | },
39 | popover: {
40 | DEFAULT: "hsl(var(--popover))",
41 | foreground: "hsl(var(--popover-foreground))",
42 | },
43 | card: {
44 | DEFAULT: "hsl(var(--card))",
45 | foreground: "hsl(var(--card-foreground))",
46 | },
47 | },
48 | borderRadius: {
49 | lg: "var(--radius)",
50 | md: "calc(var(--radius) - 2px)",
51 | sm: "calc(var(--radius) - 4px)",
52 | },
53 | keyframes: {
54 | "accordion-down": {
55 | from: { height: 0 },
56 | to: { height: "var(--radix-accordion-content-height)" },
57 | },
58 | "accordion-up": {
59 | from: { height: "var(--radix-accordion-content-height)" },
60 | to: { height: 0 },
61 | },
62 | },
63 | animation: {
64 | "accordion-down": "accordion-down 0.2s ease-out",
65 | "accordion-up": "accordion-up 0.2s ease-out",
66 | },
67 | },
68 | },
69 | plugins: [require("tailwindcss-animate")],
70 | };
71 |
--------------------------------------------------------------------------------
/curvilinear/src/utils/notification.tsx:
--------------------------------------------------------------------------------
1 | import { toast } from "react-toastify";
2 |
3 | export function showWarning(msg: string, title: string = ``) {
4 | //TODO: make notification showing from bottom
5 | const content = (
6 |
7 | {title !== `` && (
8 |
11 |
12 |
15 |
16 |
{title}
17 |
18 | )}
19 |
{msg}
20 |
21 | );
22 | toast(content, {
23 | position: `bottom-right`,
24 | });
25 | }
26 |
27 | export function showInfo(msg: string, title: string = ``) {
28 | //TODO: make notification showing from bottom
29 | const content = (
30 |
31 | {title !== `` && (
32 |
35 |
36 |
39 |
40 |
{title}
41 |
42 | )}
43 |
{msg}
44 |
45 | );
46 | toast(content, {
47 | position: `bottom-right`,
48 | });
49 | }
50 |
--------------------------------------------------------------------------------
/local-store/react/localDb.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DocumentByInfo,
3 | DocumentByName,
4 | FilterBuilder,
5 | GenericDataModel,
6 | GenericTableInfo,
7 | IndexNames,
8 | IndexRange,
9 | IndexRangeBuilder,
10 | NamedIndex,
11 | NamedTableInfo,
12 | TableNamesInDataModel,
13 | WithOptionalSystemFields,
14 | } from "convex/server";
15 | import { GenericId } from "convex/values";
16 |
17 | export interface LocalDbReader {
18 | get>(
19 | table: T,
20 | id: GenericId,
21 | ): DocumentByName | null;
22 | query>(
23 | table: T,
24 | ): QueryInitializer>;
25 | }
26 |
27 | interface QueryInitializer
28 | extends Query {
29 | withIndex>(
30 | indexName: IndexName,
31 | builder?: (
32 | q: IndexRangeBuilder<
33 | DocumentByInfo,
34 | NamedIndex
35 | >,
36 | ) => IndexRange,
37 | ): Query;
38 | }
39 |
40 | interface Query {
41 | collect(): DocumentByInfo[];
42 | take(n: number): DocumentByInfo[];
43 | unique(): DocumentByInfo | null;
44 | first(): DocumentByInfo | null;
45 | filter(
46 | filter: (q: FilterBuilder) => FilterBuilder,
47 | ): Query;
48 | order(order: "asc" | "desc"): Query;
49 | }
50 |
51 | export interface LocalDbWriter
52 | extends LocalDbReader {
53 | insert>(
54 | tableName: T,
55 | id: string,
56 | document: WithOptionalSystemFields>,
57 | ): GenericId;
58 | delete>(
59 | tableName: T,
60 | id: string,
61 | ): void;
62 | replace>(
63 | tableName: T,
64 | id: string,
65 | document: WithOptionalSystemFields>,
66 | ): void;
67 | patch>(
68 | tableName: T,
69 | id: string,
70 | document: Partial>,
71 | ): void;
72 | }
73 |
--------------------------------------------------------------------------------
/local-store/react/hooks.ts:
--------------------------------------------------------------------------------
1 | import { Value, convexToJson } from "convex/values";
2 | import { useLayoutEffect, useMemo, useState } from "react";
3 | import { useLocalStoreClient } from "./LocalStoreProvider";
4 | import { SyncQueryResult } from "../shared/types";
5 | import { DefaultFunctionArgs } from "convex/server";
6 | import { LocalQuery, LocalMutation } from "./definitionFactory";
7 |
8 | export function useLocalQuery<
9 | T extends Value,
10 | Args extends DefaultFunctionArgs,
11 | >(
12 | localQuery: LocalQuery,
13 | args: Args,
14 | debugName?: string,
15 | ): T | undefined {
16 | const localClient = useLocalStoreClient();
17 | const argsJson = JSON.stringify(
18 | convexToJson((args ?? {}) as unknown as Value),
19 | );
20 | // eslint-disable-next-line react-hooks/exhaustive-deps
21 | const stableArgs = useMemo(() => args, [argsJson]);
22 | const [result, setResult] = useState({ kind: "loading" });
23 | const fn = localQuery.handler;
24 | const fnDebugName = localQuery.debugName;
25 | const fullDebugName = [fnDebugName, debugName]
26 | .filter((d) => d !== undefined)
27 | .join(":");
28 | // By using `useLayoutEffect`, we can guarantee that we don't externalize a loading
29 | // state and cause a flicker if the data is ready locally.
30 | useLayoutEffect(() => {
31 | const syncQuerySubscriptionId = localClient.addSyncQuery(
32 | fn,
33 | stableArgs,
34 | (r) => {
35 | setResult(r);
36 | },
37 | fullDebugName,
38 | );
39 | return () => {
40 | localClient.removeSyncQuery(syncQuerySubscriptionId);
41 | };
42 | }, [localClient, fn, stableArgs, fullDebugName]);
43 | if (result.kind === "loaded") {
44 | if (result.status === "success") {
45 | return result.value as T;
46 | } else {
47 | throw result.error;
48 | }
49 | } else {
50 | return undefined;
51 | }
52 | }
53 |
54 | // This isn't really used right now in favor of `localClient.mutation`
55 | export function useLocalMutation<
56 | OptUpdateArgs extends DefaultFunctionArgs,
57 | ServerArgs extends DefaultFunctionArgs,
58 | >(
59 | mutation: LocalMutation,
60 | ): (args: OptUpdateArgs) => Promise {
61 | const localClient = useLocalStoreClient();
62 | return async (args: OptUpdateArgs) => {
63 | await localClient.mutation(mutation, args as any);
64 | };
65 | }
66 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/images/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/curvilinear/src/components/Avatar.tsx:
--------------------------------------------------------------------------------
1 | import { MouseEventHandler } from "react";
2 | import classnames from "classnames";
3 | import AvatarImg from "../assets/icons/avatar.svg";
4 |
5 | interface Props {
6 | online?: boolean;
7 | showOffline?: boolean;
8 | name?: string;
9 | avatarUrl?: string;
10 | onClick?: MouseEventHandler | undefined;
11 | }
12 |
13 | //bg-blue-500
14 |
15 | function stringToHslColor(str: string, s: number, l: number) {
16 | let hash = 0;
17 | for (let i = 0; i < str.length; i++) {
18 | hash = str.charCodeAt(i) + ((hash << 5) - hash);
19 | }
20 |
21 | const h = hash % 360;
22 | return `hsl(` + h + `, ` + s + `%, ` + l + `%)`;
23 | }
24 |
25 | function getAcronym(name: string) {
26 | let acr = ((name || ``).match(/\b(\w)/g) || [])
27 | .join(``)
28 | .slice(0, 2)
29 | .toUpperCase();
30 | if (acr.length === 1) {
31 | acr = acr + name.slice(1, 2).toLowerCase();
32 | }
33 | return acr;
34 | }
35 | function Avatar({ online, showOffline, name, onClick, avatarUrl }: Props) {
36 | let avatar, status;
37 |
38 | // create avatar image icon
39 | // XXX: Sync users everywhere.
40 | if (avatarUrl && false)
41 | avatar = (
42 |
48 | );
49 | else if (name !== undefined) {
50 | // use name as avatar
51 | avatar = (
52 |
56 | {getAcronym(name)}
57 |
58 | );
59 | } else {
60 | // try to use default avatar
61 | avatar = (
62 |
63 | );
64 | }
65 |
66 | //status icon
67 | if (online || showOffline)
68 | status = (
69 | //
70 |
79 | );
80 | else status = null;
81 |
82 | return (
83 |
84 | {avatar}
85 | {status}
86 |
87 | );
88 | }
89 |
90 | export default Avatar;
91 |
--------------------------------------------------------------------------------
/curvilinear/src/local/mutations.ts:
--------------------------------------------------------------------------------
1 | import { api } from "../../convex/_generated/api";
2 | import { FunctionArgs } from "convex/server";
3 | import { getIssueById } from "./queries";
4 | import { factory } from "./types";
5 |
6 | export const createIssue = factory.defineLocalMutation(
7 | api.issues.createIssue,
8 | (ctx, args: FunctionArgs) => {
9 | ctx.localDb.insert("issues", args.id, args);
10 | },
11 | );
12 |
13 | export const changeStatus = factory.defineLocalMutation(
14 | api.issues.changeStatus,
15 | (ctx, args: FunctionArgs) => {
16 | const issue = getIssueById.handler(ctx, { id: args.id });
17 | if (!issue) {
18 | throw new Error("Issue not found");
19 | }
20 | ctx.localDb.replace("issues", args.id, {
21 | ...issue,
22 | status: args.status,
23 | });
24 | },
25 | );
26 |
27 | export const changePriority = factory.defineLocalMutation(
28 | api.issues.changePriority,
29 | (ctx, args: FunctionArgs) => {
30 | const issue = getIssueById.handler(ctx, { id: args.id });
31 | if (!issue) {
32 | throw new Error("Issue not found");
33 | }
34 | ctx.localDb.replace("issues", args.id, {
35 | ...issue,
36 | priority: args.priority,
37 | });
38 | },
39 | );
40 |
41 | export const changeTitle = factory.defineLocalMutation(
42 | api.issues.changeTitle,
43 | (ctx, args: FunctionArgs) => {
44 | const issue = getIssueById.handler(ctx, { id: args.id });
45 | if (!issue) {
46 | throw new Error("Issue not found");
47 | }
48 | ctx.localDb.replace("issues", args.id, {
49 | ...issue,
50 | title: args.title,
51 | });
52 | },
53 | );
54 |
55 | export const changeDescription = factory.defineLocalMutation(
56 | api.issues.changeDescription,
57 | (ctx, args: FunctionArgs) => {
58 | const issue = getIssueById.handler(ctx, { id: args.id });
59 | if (!issue) {
60 | throw new Error("Issue not found");
61 | }
62 | ctx.localDb.replace("issues", args.id, {
63 | ...issue,
64 | title: args.description,
65 | });
66 | },
67 | );
68 |
69 | export const deleteIssue = factory.defineLocalMutation(
70 | api.issues.deleteIssue,
71 | (ctx, args: FunctionArgs) => {
72 | ctx.localDb.delete("issues", args.id);
73 | },
74 | );
75 |
76 | export const postComment = factory.defineLocalMutation(
77 | api.comments.postComment,
78 | (ctx, args: FunctionArgs) => {
79 | ctx.localDb.insert("comments", args.id, args);
80 | },
81 | );
82 |
--------------------------------------------------------------------------------
/curvilinear/src/pages/List/IssueRow.tsx:
--------------------------------------------------------------------------------
1 | import type { CSSProperties } from "react";
2 | import PriorityMenu from "../../components/contextmenu/PriorityMenu";
3 | import StatusMenu from "../../components/contextmenu/StatusMenu";
4 | import PriorityIcon from "../../components/PriorityIcon";
5 | import StatusIcon from "../../components/StatusIcon";
6 | import Avatar from "../../components/Avatar";
7 | import { memo } from "react";
8 | import { useNavigate } from "react-router-dom";
9 | import { formatDate } from "../../utils/date";
10 | import { Issue } from "../../types/types";
11 | import { useLocalStoreClient } from "local-store/react/LocalStoreProvider";
12 | import { changePriority, changeStatus } from "@/local/mutations";
13 |
14 | interface Props {
15 | issue: Issue;
16 | style: CSSProperties;
17 | }
18 |
19 | function IssueRow({ issue, style }: Props) {
20 | const navigate = useNavigate();
21 | const client = useLocalStoreClient();
22 |
23 | const handleChangeStatus = async (status: string) => {
24 | const args = {
25 | id: issue.id,
26 | status,
27 | };
28 | await client.mutation(changeStatus, args);
29 | };
30 |
31 | const handleChangePriority = async (priority: string) => {
32 | const args = {
33 | id: issue.id,
34 | priority,
35 | };
36 | await client.mutation(changePriority, args);
37 | };
38 |
39 | return (
40 | navigate(`/issue/${issue.id}`)}
45 | style={style}
46 | >
47 |
48 |
}
51 | onSelect={handleChangePriority}
52 | />
53 |
54 |
55 | }
58 | onSelect={handleChangeStatus as any}
59 | />
60 |
61 |
62 | {issue.title.slice(0, 3000) || ``}
63 |
64 |
65 | {formatDate(new Date(issue.created))}
66 |
67 |
70 |
71 | );
72 | }
73 |
74 | export default memo(IssueRow);
75 |
--------------------------------------------------------------------------------
/curvilinear/convex/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to your Convex functions directory!
2 |
3 | Write your Convex functions here.
4 | See https://docs.convex.dev/functions for more.
5 |
6 | A query function that takes two arguments looks like:
7 |
8 | ```ts
9 | // functions.js
10 | import { query } from "./_generated/server";
11 | import { v } from "convex/values";
12 |
13 | export const myQueryFunction = query({
14 | // Validators for arguments.
15 | args: {
16 | first: v.number(),
17 | second: v.string(),
18 | },
19 |
20 | // Function implementation.
21 | handler: async (ctx, args) => {
22 | // Read the database as many times as you need here.
23 | // See https://docs.convex.dev/database/reading-data.
24 | const documents = await ctx.db.query("tablename").collect();
25 |
26 | // Arguments passed from the client are properties of the args object.
27 | console.log(args.first, args.second);
28 |
29 | // Write arbitrary JavaScript here: filter, aggregate, build derived data,
30 | // remove non-public properties, or create new objects.
31 | return documents;
32 | },
33 | });
34 | ```
35 |
36 | Using this query function in a React component looks like:
37 |
38 | ```ts
39 | const data = useQuery(api.functions.myQueryFunction, {
40 | first: 10,
41 | second: "hello",
42 | });
43 | ```
44 |
45 | A mutation function looks like:
46 |
47 | ```ts
48 | // functions.js
49 | import { mutation } from "./_generated/server";
50 | import { v } from "convex/values";
51 |
52 | export const myMutationFunction = mutation({
53 | // Validators for arguments.
54 | args: {
55 | first: v.string(),
56 | second: v.string(),
57 | },
58 |
59 | // Function implementation.
60 | handler: async (ctx, args) => {
61 | // Insert or modify documents in the database here.
62 | // Mutations can also read from the database like queries.
63 | // See https://docs.convex.dev/database/writing-data.
64 | const message = { body: args.first, author: args.second };
65 | const id = await ctx.db.insert("messages", message);
66 |
67 | // Optionally, return a value from your mutation.
68 | return await ctx.db.get(id);
69 | },
70 | });
71 | ```
72 |
73 | Using this mutation function in a React component looks like:
74 |
75 | ```ts
76 | const mutation = useMutation(api.functions.myMutationFunction);
77 | function handleButtonPress() {
78 | // fire and forget, the most common way to use mutations
79 | mutation({ first: "Hello!", second: "me" });
80 | // OR
81 | // use the result once the mutation has completed
82 | mutation({ first: "Hello!", second: "me" }).then((result) =>
83 | console.log(result),
84 | );
85 | }
86 | ```
87 |
88 | Use the Convex CLI to push your functions to a deployment. See everything
89 | the Convex CLI can do by running `npx convex -h` in your project root
90 | directory. To learn more, launch the docs with `npx convex docs`.
91 |
--------------------------------------------------------------------------------
/curvilinear/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { SignInButton } from "@clerk/clerk-react";
2 | import { Unauthenticated } from "convex/react";
3 | import "animate.css/animate.min.css";
4 | import { useState, createContext } from "react";
5 | import { createBrowserRouter, RouterProvider } from "react-router-dom";
6 | import "react-toastify/dist/ReactToastify.css";
7 | import List from "./pages/List";
8 | import Root from "./pages/root";
9 | import Issue from "./pages/Issue";
10 | import { localStore } from "./convex";
11 | import { preload } from "./local/queries";
12 | import { useCachedUser } from "./hooks/useUser";
13 |
14 | interface MenuContextInterface {
15 | showMenu: boolean;
16 | setShowMenu: (show: boolean) => void;
17 | }
18 |
19 | export const MenuContext = createContext(null as MenuContextInterface | null);
20 |
21 | const router = createBrowserRouter([
22 | {
23 | path: `/`,
24 | element: ,
25 | loader: async () => {
26 | console.time(`preload`);
27 | await new Promise((resolve, reject) => {
28 | const subscriptionId = localStore.addSyncQuery(
29 | preload.handler,
30 | {},
31 | (result) => {
32 | if (result.kind === "loading") {
33 | return;
34 | }
35 | // XXX: Have the layer above pass in `subscriptionId` to the callback.
36 | queueMicrotask(() => localStore.removeSyncQuery(subscriptionId));
37 | if (result.status === "success") {
38 | resolve(result);
39 | } else {
40 | reject(result.error);
41 | }
42 | },
43 | "preload",
44 | );
45 | });
46 | console.timeEnd(`preload`);
47 | return null;
48 | },
49 | children: [
50 | {
51 | path: `/`,
52 | element:
,
53 | },
54 | {
55 | path: `/search`,
56 | element:
,
57 | },
58 | {
59 | path: `/issue/:id`,
60 | element: ,
61 | },
62 | ],
63 | },
64 | ]);
65 |
66 | const App = () => {
67 | const [showMenu, setShowMenu] = useState(false);
68 | // Prepopulate the user in local storage if the user is signed in.
69 | useCachedUser();
70 | return (
71 |
72 |
73 |
74 |
75 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | );
89 | };
90 |
91 | export default App;
92 |
--------------------------------------------------------------------------------
/curvilinear/src/components/contextmenu/menu.tsx:
--------------------------------------------------------------------------------
1 | import classnames from "classnames";
2 | import { ReactNode, useRef } from "react";
3 | import {
4 | ContextMenu,
5 | MenuItem,
6 | type MenuItemProps as CMMenuItemProps,
7 | } from "@firefox-devtools/react-contextmenu";
8 |
9 | const sizeClasses = {
10 | small: `w-34`,
11 | normal: `w-72`,
12 | };
13 |
14 | export interface MenuProps {
15 | id: string;
16 | size: keyof typeof sizeClasses;
17 | className?: string;
18 | onKeywordChange?: (kw: string) => void;
19 | filterKeyword: boolean;
20 | children: ReactNode;
21 | searchPlaceholder?: string;
22 | }
23 |
24 | interface MenuItemProps {
25 | children: ReactNode;
26 | onClick?: CMMenuItemProps[`onClick`];
27 | }
28 | const Item = function ({ onClick, children }: MenuItemProps) {
29 | return (
30 |
36 | );
37 | };
38 |
39 | const Divider = function () {
40 | return ;
41 | };
42 |
43 | const Header = function ({ children }: MenuItemProps) {
44 | return (
45 |
48 | );
49 | };
50 |
51 | export const Menu = (props: MenuProps) => {
52 | const {
53 | id,
54 | size = `small`,
55 | onKeywordChange,
56 | children,
57 | className,
58 | filterKeyword,
59 | searchPlaceholder,
60 | } = props;
61 | const ref = useRef(null);
62 |
63 | const classes = classnames(
64 | `cursor-default bg-white rounded shadow-modal z-100`,
65 | sizeClasses[size],
66 | className,
67 | );
68 |
69 | return (
70 | {
75 | if (ref.current) ref.current.focus();
76 | }}
77 | >
78 | {
80 | e.stopPropagation();
81 | }}
82 | >
83 | {filterKeyword && (
84 | {
88 | if (onKeywordChange) onKeywordChange(e.target.value);
89 | }}
90 | onClick={(e) => {
91 | e.stopPropagation();
92 | }}
93 | placeholder={searchPlaceholder}
94 | />
95 | )}
96 | {children}
97 |
98 |
99 | );
100 | };
101 |
102 | Menu.Item = Item;
103 | Menu.Divider = Divider;
104 | Menu.Header = Header;
105 |
--------------------------------------------------------------------------------
/curvilinear/src/utils/filterState.ts:
--------------------------------------------------------------------------------
1 | import { useSearchParams } from "react-router-dom";
2 |
3 | interface FilterState {
4 | orderBy: string;
5 | orderDirection: `asc` | `desc`;
6 | status?: string[];
7 | priority?: string[];
8 | query?: string;
9 | }
10 |
11 | export function useFilterState(): [
12 | FilterState,
13 | (state: Partial) => void,
14 | ] {
15 | const [searchParams, setSearchParams] = useSearchParams();
16 | const orderBy = searchParams.get(`orderBy`) ?? `created`;
17 | const orderDirection =
18 | (searchParams.get(`orderDirection`) as `asc` | `desc`) ?? `desc`;
19 | const status = searchParams
20 | .getAll(`status`)
21 | .map((status) => status.toLocaleLowerCase().split(`,`))
22 | .flat();
23 | const priority = searchParams
24 | .getAll(`priority`)
25 | .map((status) => status.toLocaleLowerCase().split(`,`))
26 | .flat();
27 | const query = searchParams.get(`query`);
28 |
29 | const state = {
30 | orderBy,
31 | orderDirection,
32 | status,
33 | priority,
34 | query: query || undefined,
35 | };
36 |
37 | const setState = (state: Partial) => {
38 | const { orderBy, orderDirection, status, priority, query } = state;
39 | setSearchParams((searchParams) => {
40 | if (orderBy) {
41 | searchParams.set(`orderBy`, orderBy);
42 | } else {
43 | searchParams.delete(`orderBy`);
44 | }
45 | if (orderDirection) {
46 | searchParams.set(`orderDirection`, orderDirection);
47 | } else {
48 | searchParams.delete(`orderDirection`);
49 | }
50 | if (status && status.length > 0) {
51 | searchParams.set(`status`, status.join(`,`));
52 | } else {
53 | searchParams.delete(`status`);
54 | }
55 | if (priority && priority.length > 0) {
56 | searchParams.set(`priority`, priority.join(`,`));
57 | } else {
58 | searchParams.delete(`priority`);
59 | }
60 | if (query) {
61 | searchParams.set(`query`, query);
62 | } else {
63 | searchParams.delete(`query`);
64 | }
65 | return searchParams;
66 | });
67 | };
68 |
69 | return [state, setState];
70 | }
71 |
72 | interface FilterStateWhere {
73 | status?: { in: string[] };
74 | priority?: { in: string[] };
75 | title?: { contains: string };
76 | OR?: [{ title: { contains: string } }, { description: { contains: string } }];
77 | }
78 |
79 | export function filterStateToWhere(filterState: FilterState) {
80 | const { status, priority, query } = filterState;
81 | const where: FilterStateWhere = {};
82 | if (status && status.length > 0) {
83 | where.status = { in: status };
84 | }
85 | if (priority && priority.length > 0) {
86 | where.priority = { in: priority };
87 | }
88 | if (query) {
89 | where.OR = [
90 | { title: { contains: query } },
91 | { description: { contains: query } },
92 | ];
93 | }
94 | return where;
95 | }
96 |
--------------------------------------------------------------------------------
/local-store/browser/network.ts:
--------------------------------------------------------------------------------
1 | import { BaseConvexClient, FunctionResult, QueryToken } from "convex/browser";
2 | import {
3 | ConvexSubscriptionId,
4 | MutationId,
5 | MutationInfo,
6 | PageArguments,
7 | SyncFunction,
8 | } from "../shared/types";
9 | import { getFunctionName } from "convex/server";
10 |
11 | type Unsubscribe = () => void;
12 |
13 | export interface Network {
14 | sendQueryToNetwork(syncFunction: SyncFunction, args: PageArguments): void;
15 | removeQueryFromNetwork(queriesToRemove: ConvexSubscriptionId[]): void;
16 | sendMutationToNetwork(mutationInfo: MutationInfo): void;
17 | getMutationId(requestId: number): MutationId | null;
18 | convexClient: BaseConvexClient;
19 | addOnTransitionHandler(handler: (transition: Transition) => void): void;
20 | }
21 |
22 | export class NetworkImpl implements Network {
23 | private subscriptions: Map = new Map();
24 | private mutationMapping: Map<
25 | number,
26 | {
27 | mutationId: MutationId;
28 | result: Promise;
29 | }
30 | > = new Map();
31 |
32 | convexClient: BaseConvexClient;
33 | constructor(opts: { convexClient: BaseConvexClient }) {
34 | this.convexClient = opts.convexClient;
35 | }
36 |
37 | sendQueryToNetwork(syncFunction: SyncFunction, args: PageArguments): void {
38 | const { queryToken, unsubscribe } = this.convexClient.subscribe(
39 | getFunctionName(syncFunction),
40 | args as any,
41 | );
42 | this.subscriptions.set(queryToken as ConvexSubscriptionId, unsubscribe);
43 | }
44 |
45 | removeQueryFromNetwork(queriesToRemove: ConvexSubscriptionId[]): void {
46 | for (const query of queriesToRemove) {
47 | const unsubscribe = this.subscriptions.get(query);
48 | if (unsubscribe) {
49 | unsubscribe();
50 | }
51 | this.subscriptions.delete(query);
52 | }
53 | }
54 |
55 | sendMutationToNetwork(mutationInfo: MutationInfo): void {
56 | const { requestId, mutationPromise } = this.convexClient.enqueueMutation(
57 | getFunctionName(mutationInfo.mutationPath),
58 | mutationInfo.serverArgs as any,
59 | );
60 | this.mutationMapping.set(requestId, {
61 | mutationId: mutationInfo.mutationId,
62 | result: mutationPromise,
63 | });
64 | }
65 |
66 | getMutationId(requestId: number): MutationId | null {
67 | const mutation = this.mutationMapping.get(requestId);
68 | if (!mutation) {
69 | return null;
70 | }
71 | return mutation.mutationId;
72 | }
73 |
74 | addOnTransitionHandler(handler: (transition: Transition) => void) {
75 | this.convexClient.addOnTransitionHandler(handler);
76 | }
77 | }
78 |
79 | // TODO: Import these from convex
80 | type QueryModification =
81 | // `undefined` generally comes from an optimistic update setting the query to be loading
82 | { kind: "Updated"; result: FunctionResult | undefined } | { kind: "Removed" };
83 |
84 | type Transition = {
85 | queries: Array<{ token: QueryToken; modification: QueryModification }>;
86 | reflectedMutations: Array<{ requestId: any; result: FunctionResult }>;
87 | timestamp: any;
88 | };
89 |
--------------------------------------------------------------------------------
/curvilinear/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "linear-convex",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "npm-run-all --parallel dev:frontend dev:backend",
8 | "dev:frontend": "vite",
9 | "dev:backend": "convex dev",
10 | "build": "tsc && vite build",
11 | "lint": "tsc && eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
12 | "format": "prettier --write .",
13 | "preview": "vite preview"
14 | },
15 | "dependencies": {
16 | "@clerk/clerk-react": "^4.25.2",
17 | "@firefox-devtools/react-contextmenu": "^5.1.1",
18 | "@headlessui/react": "^1.7.17",
19 | "@svgr/plugin-jsx": "^8.1.0",
20 | "@svgr/plugin-svgo": "^8.1.0",
21 | "@tailwindcss/forms": "^0.5.6",
22 | "@tiptap/extension-placeholder": "^2.4.0",
23 | "@tiptap/extension-table": "^2.4.0",
24 | "@tiptap/extension-table-cell": "^2.4.0",
25 | "@tiptap/extension-table-header": "^2.4.0",
26 | "@tiptap/extension-table-row": "^2.4.0",
27 | "@tiptap/pm": "^2.4.0",
28 | "@tiptap/react": "^2.4.0",
29 | "@tiptap/starter-kit": "^2.4.0",
30 | "animate.css": "^4.1.1",
31 | "classnames": "^2.5.1",
32 | "clsx": "^2.0.0",
33 | "convex": "^1.23.0",
34 | "convex-helpers": "^0.1.79",
35 | "dayjs": "^1.11.11",
36 | "dotenv": "^16.4.5",
37 | "fractional-indexing": "^3.2.0",
38 | "jsonwebtoken": "^9.0.2",
39 | "local-store": "workspace:*",
40 | "lodash.debounce": "^4.0.8",
41 | "openai": "^4.76.2",
42 | "react": "^18.3.1",
43 | "react-beautiful-dnd": "^13.1.1",
44 | "react-dom": "^18.3.1",
45 | "react-icons": "^4.10.1",
46 | "react-markdown": "^8.0.7",
47 | "react-router-dom": "^6.24.1",
48 | "react-toastify": "^9.1.3",
49 | "react-virtualized-auto-sizer": "^1.0.24",
50 | "react-window": "^1.8.10",
51 | "tailwind-merge": "^1.14.0",
52 | "tailwindcss-animate": "^1.0.7",
53 | "tiptap-markdown": "^0.8.2",
54 | "uuid": "^9.0.0",
55 | "vite-plugin-svgr": "^3.2.0",
56 | "zod": "^3.24.1"
57 | },
58 | "devDependencies": {
59 | "@tailwindcss/typography": "^0.5.10",
60 | "@types/jest": "^29.5.12",
61 | "@types/lodash.debounce": "^4.0.9",
62 | "@types/node": "^20.14.10",
63 | "@types/react": "^18.3.3",
64 | "@types/react-beautiful-dnd": "^13.1.8",
65 | "@types/react-dom": "^18.3.0",
66 | "@types/react-router-dom": "^5.3.3",
67 | "@types/react-window": "^1.8.8",
68 | "@types/uuid": "^9.0.3",
69 | "@types/vite-plugin-react-svg": "^0.2.5",
70 | "@typescript-eslint/eslint-plugin": "^6.0.0",
71 | "@typescript-eslint/parser": "^6.0.0",
72 | "@vitejs/plugin-react": "^4.3.1",
73 | "autoprefixer": "^10.4.19",
74 | "dotenv-cli": "^7.4.2",
75 | "eslint": "^8.57.0",
76 | "eslint-config-prettier": "^9.1.0",
77 | "eslint-plugin-prettier": "^5.1.3",
78 | "eslint-plugin-react-hooks": "^4.6.2",
79 | "eslint-plugin-react-refresh": "^0.4.3",
80 | "fs-extra": "^10.0.0",
81 | "npm-run-all": "^4.1.5",
82 | "postcss": "^8.4.39",
83 | "prettier": "^3.3.2",
84 | "tailwindcss": "^3.4.4",
85 | "typescript": "^5.5.3",
86 | "vite": "^4.4.5"
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/curvilinear/src/components/ViewOptionMenu.tsx:
--------------------------------------------------------------------------------
1 | import { Transition } from "@headlessui/react";
2 | import { useClickOutside } from "../hooks/useClickOutside";
3 | import { useRef } from "react";
4 | import Select from "./Select";
5 | import { useFilterState } from "../utils/filterState";
6 |
7 | interface Props {
8 | isOpen: boolean;
9 | onDismiss?: () => void;
10 | }
11 | export default function ({ isOpen, onDismiss }: Props) {
12 | const ref = useRef(null);
13 | const [filterState, setFilterState] = useFilterState();
14 |
15 | useClickOutside(ref, () => {
16 | if (isOpen && onDismiss) onDismiss();
17 | });
18 |
19 | const handleOrderByChange = (e: React.ChangeEvent) => {
20 | setFilterState({
21 | ...filterState,
22 | orderBy: e.target.value,
23 | });
24 | };
25 |
26 | const handleOrderDirectionChange = (
27 | e: React.ChangeEvent,
28 | ) => {
29 | setFilterState({
30 | ...filterState,
31 | orderDirection: e.target.value as `asc` | `desc`,
32 | });
33 | };
34 |
35 | return (
36 |
37 |
47 |
48 | View Options
49 |
50 |
51 |
52 | {/*
53 |
Grouping
54 |
55 |
62 |
63 |
*/}
64 |
65 |
66 |
Ordering
67 |
68 |
77 |
78 |
79 |
86 |
87 |
88 |
89 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Convex local sync + Linear clone
2 |
3 | This repo is an alpha of the offline sync engine for Convex. See a deployed version of the demo [here](https://linear-convex.vercel.app/).
4 |
5 | ## Installation
6 | This monorepo depends on [PNPM](https://pnpm.io/installation) for package management.
7 |
8 | ```bash
9 | pnpm i
10 | cd curvilinear
11 | npm run dev
12 | ```
13 |
14 | Note that you can either [use a local dev server](https://stack.convex.dev/anonymous-development) without signing up for a Convex account or use a managed
15 | cloud deployment for running the backend.
16 |
17 | ## App
18 |
19 | Vite serves the app on port 5173. It's pretty minimal Linear clone that demonstrates:
20 |
21 | 1. Fast preloading of a page's content on initial load without any spinners
22 | 2. Storing application state in IndexedDB for offline support
23 | 3. Fully local client navigations that don't block on the server and show a spinner.
24 | 4. Mutations for creating, editing, and deleting tasks.
25 |
26 | ### Schema
27 |
28 | The server-side schema is defined at `convex/schema.ts`. We have two tables, `issues`
29 | and `comments`.
30 |
31 | The local schema is defined as a *projection* of the server-side schema at
32 | `convex/sync/schema.ts`. There are two local tables, `issues` and `comments`, and their
33 | relationship to the underlying server tables are at `convex/sync/{issues,comments}.ts`.
34 |
35 | We currently don't have any non-trivial mapping logic between the two data models,
36 | so the logic in `convex/sync/issues.ts` just directly maps queries to the local table
37 | to queries to the underlying sync table.
38 |
39 | ### Queries
40 |
41 | On the client, the app's queries against the local store are specified in
42 | `src/local/queries.ts`. These synchronous callbacks issue queries against a
43 | `ctx.localDb` object, and the sync engine handles pulling in data from the
44 | server to fulfill these queries.
45 |
46 | React app components, like `src/pages/Issue/Comments.tsx` use the `useLocalQuery`
47 | hook to execute one of these callbacks. The hook returns `undefined` if the data
48 | isn't available yet, which may happen if not all relevant data was preloaded.
49 |
50 | ### Mutations
51 |
52 | The client specifies its mutations in `src/local/mutations.ts`. These are centrally
53 | defined since the sync engine needs to know how to replay in-progress mutations
54 | on restart.
55 |
56 | Mutations contain both a pointer to an authoritative server-side mutation (e.g.
57 | `api.issues.changeStatus`) as well as a callback for mutating the local store. These
58 | don't have to agree, and the sync engine will coordinate atomically rolling back
59 | the local store update once the server-side change is complete and visible.
60 |
61 | Server-side mutations are fully serializable, so a wide range of conflict resolution
62 | strategies are possible.
63 |
64 | ## Known issues
65 |
66 | 1. Correctness. We have deterministic simulation testing set up but haven't exercised it and wrung out all the bugs yet.
67 | 2. Scale and performance. We've not optimized this for large numbers of rows managed yet.
68 | 3. Making the website part offline with PWA
69 | 4. API for reactively querying the set of in-progress mutations
70 | 5. Native client ID allocation
71 | 6. Synchronization across multiple tabs
72 | 7. Versioning and local database migrations
73 | 8. The naming isn't great, and the APIs need a lot of polish.
74 | 9. Codesharing between local optimistic updates and the authoritative server mutation.
75 |
--------------------------------------------------------------------------------
/curvilinear/src/components/Modal.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | memo,
3 | RefObject,
4 | useCallback,
5 | useRef,
6 | type MouseEvent,
7 | } from "react";
8 | import ReactDOM from "react-dom";
9 | import classnames from "classnames";
10 |
11 | import CloseIconImg from "../assets/icons/close.svg";
12 | import useLockBodyScroll from "../hooks/useLockBodyScroll";
13 | import { Transition } from "@headlessui/react";
14 |
15 | interface Props {
16 | title?: string;
17 | isOpen: boolean;
18 | center?: boolean;
19 | className?: string;
20 | /* function called when modal is closed */
21 | onDismiss?: () => void;
22 | children?: React.ReactNode;
23 | size?: keyof typeof sizeClasses;
24 | }
25 | const sizeClasses = {
26 | large: `w-400`,
27 | normal: `w-140`,
28 | };
29 |
30 | function Modal({
31 | title,
32 | isOpen,
33 | center = true,
34 | size = `normal`,
35 | className,
36 | onDismiss,
37 | children,
38 | }: Props) {
39 | const ref = useRef(null) as RefObject;
40 | const outerRef = useRef(null);
41 |
42 | const wrapperClasses = classnames(
43 | `fixed flex flex-col items-center inset-0 z-50`,
44 | {
45 | "justify-center": center,
46 | },
47 | );
48 | const modalClasses = classnames(
49 | `flex flex-col items-center overflow-hidden transform bg-white modal shadow-large-modal rounded-xl border-gray-200`,
50 | {
51 | "mt-20 mb-2 ": !center,
52 | },
53 | sizeClasses[size],
54 | className,
55 | );
56 | const handleClick = useCallback((event: MouseEvent) => {
57 | event.stopPropagation();
58 | event.preventDefault();
59 | if (!onDismiss) return;
60 | if (ref.current && !ref.current.contains(event.target as Element)) {
61 | onDismiss();
62 | }
63 | }, []);
64 |
65 | useLockBodyScroll();
66 |
67 | const modal = (
68 |
69 |
79 | {/* XXX: Why don't the tailwind classes set above apply? */}
80 |
88 | {title && (
89 |
90 |
{title}
91 |
92 |

97 |
98 |
99 | )}
100 | {children}
101 |
102 |
103 |
104 | );
105 |
106 | return ReactDOM.createPortal(
107 | modal,
108 | document.getElementById(`root-modal`) as Element,
109 | );
110 | }
111 |
112 | export default memo(Modal);
113 |
--------------------------------------------------------------------------------
/curvilinear/src/types/types.ts:
--------------------------------------------------------------------------------
1 | import CancelIconImg from "../assets/icons/cancel.svg";
2 | import BacklogIconImg from "../assets/icons/circle-dot.svg";
3 | import TodoIconImg from "../assets/icons/circle.svg";
4 | import DoneIconImg from "../assets/icons/done.svg";
5 | import InProgressIconImg from "../assets/icons/half-circle.svg";
6 | import HighPriorityIconImg from "../assets/icons/signal-strong.svg";
7 | import LowPriorityIconImg from "../assets/icons/signal-weak.svg";
8 | import MediumPriorityIconImg from "../assets/icons/signal-medium.svg";
9 | import NoPriorityIconImg from "../assets/icons/dots.svg";
10 | import UrgentPriorityIconImg from "../assets/icons/rounded-claim.svg";
11 |
12 | export type Issue = {
13 | id: string;
14 | title: string;
15 | description: string;
16 | priority: (typeof Priority)[keyof typeof Priority];
17 | status: (typeof Status)[keyof typeof Status];
18 | modified: number;
19 | created: number;
20 | username: string;
21 | };
22 |
23 | export type Comment = {
24 | id: string;
25 | body: string;
26 | username: string;
27 | issue_id: string;
28 | created_at: number;
29 | };
30 |
31 | export const Priority = {
32 | NONE: `none`,
33 | URGENT: `urgent`,
34 | HIGH: `high`,
35 | LOW: `low`,
36 | MEDIUM: `medium`,
37 | };
38 |
39 | export const PriorityDisplay = {
40 | [Priority.NONE]: `None`,
41 | [Priority.URGENT]: `Urgent`,
42 | [Priority.HIGH]: `High`,
43 | [Priority.LOW]: `Low`,
44 | [Priority.MEDIUM]: `Medium`,
45 | };
46 |
47 | export const PriorityIcons = {
48 | [Priority.NONE]: NoPriorityIconImg,
49 | [Priority.URGENT]: UrgentPriorityIconImg,
50 | [Priority.HIGH]: HighPriorityIconImg,
51 | [Priority.MEDIUM]: MediumPriorityIconImg,
52 | [Priority.LOW]: LowPriorityIconImg,
53 | };
54 |
55 | export const PriorityOptions: [
56 | string,
57 | string,
58 | (typeof Priority)[keyof typeof Priority],
59 | ][] = [
60 | [PriorityIcons[Priority.NONE], Priority.NONE, `None`],
61 | [PriorityIcons[Priority.URGENT], Priority.URGENT, `Urgent`],
62 | [PriorityIcons[Priority.HIGH], Priority.HIGH, `High`],
63 | [PriorityIcons[Priority.MEDIUM], Priority.MEDIUM, `Medium`],
64 | [PriorityIcons[Priority.LOW], Priority.LOW, `Low`],
65 | ];
66 |
67 | export const Status = {
68 | BACKLOG: `backlog`,
69 | TODO: `todo`,
70 | IN_PROGRESS: `in_progress`,
71 | DONE: `done`,
72 | CANCELED: `canceled`,
73 | };
74 |
75 | export const StatusDisplay = {
76 | [Status.BACKLOG]: `Backlog`,
77 | [Status.TODO]: `To Do`,
78 | [Status.IN_PROGRESS]: `In Progress`,
79 | [Status.DONE]: `Done`,
80 | [Status.CANCELED]: `Canceled`,
81 | };
82 |
83 | export const StatusIcons = {
84 | [Status.BACKLOG]: BacklogIconImg,
85 | [Status.TODO]: TodoIconImg,
86 | [Status.IN_PROGRESS]: InProgressIconImg,
87 | [Status.DONE]: DoneIconImg,
88 | [Status.CANCELED]: CancelIconImg,
89 | };
90 |
91 | export const StatusOptions: [
92 | string,
93 | (typeof Status)[keyof typeof Status],
94 | string,
95 | ][] = [
96 | [StatusIcons[Status.BACKLOG], Status.BACKLOG, StatusDisplay[Status.BACKLOG]],
97 | [StatusIcons[Status.TODO], Status.TODO, StatusDisplay[Status.TODO]],
98 | [
99 | StatusIcons[Status.IN_PROGRESS],
100 | Status.IN_PROGRESS,
101 | StatusDisplay[Status.IN_PROGRESS],
102 | ],
103 | [StatusIcons[Status.DONE], Status.DONE, StatusDisplay[Status.DONE]],
104 | [
105 | StatusIcons[Status.CANCELED],
106 | Status.CANCELED,
107 | StatusDisplay[Status.CANCELED],
108 | ],
109 | ];
110 |
--------------------------------------------------------------------------------
/local-store/test/browser/localStore.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "vitest";
2 | import { CopyOnWriteLocalStore } from "../../browser/core/localStore";
3 | import { sync as syncSchema } from "../../../simulation/convex/sync/schema";
4 | import { anyApi, defineSchema, defineTable } from "convex/server";
5 | import { v } from "convex/values";
6 | import { Writes } from "../../browser/core/protocol";
7 | import {
8 | LOG2_PAGE_SIZE,
9 | SingleIndexRangeExecutor,
10 | indexRangeUnbounded,
11 | } from "../../browser/core/paginator";
12 | import { createQueryToken } from "../../shared/queryTokens";
13 | import { PageArguments, PageResult } from "../../shared/types";
14 |
15 | export const sync = defineSchema({
16 | // specific to current user
17 | conversations: defineTable({
18 | _id: v.string(),
19 | latestMessageTime: v.number(),
20 | emoji: v.optional(v.string()),
21 | users: v.array(v.id("users")),
22 | hasUnreadMessages: v.boolean(),
23 | }).index("by_priority", ["hasUnreadMessages", "latestMessageTime"]),
24 | });
25 |
26 | test("applyWrites", async () => {
27 | const localStore = new CopyOnWriteLocalStore(syncSchema);
28 | const pageArguments: PageArguments = {
29 | syncTableName: "conversations",
30 | index: "by_priority",
31 | target: { kind: "successor", value: [] },
32 | log2PageSize: LOG2_PAGE_SIZE,
33 | };
34 | const pageResult: PageResult = {
35 | results: [],
36 | lowerBound: { kind: "predecessor", value: [] },
37 | upperBound: { kind: "successor", value: [] },
38 | };
39 | localStore.ingest([
40 | {
41 | tableName: "conversations",
42 | indexName: "by_priority",
43 | convexSubscriptionId: createQueryToken(
44 | anyApi.sync.conversations.by_priority,
45 | pageArguments,
46 | ),
47 | state: {
48 | kind: "loaded",
49 | value: pageResult,
50 | },
51 | },
52 | ]);
53 | const writesA = new Writes();
54 | writesA.set("conversations", "1" as any, {
55 | _id: "1",
56 | latestMessageTime: 1,
57 | hasUnreadMessages: true,
58 | emoji: "A",
59 | users: [],
60 | });
61 | localStore.applyWrites(writesA);
62 | const paginator = new SingleIndexRangeExecutor(
63 | {
64 | tableName: "conversations",
65 | indexName: "by_priority",
66 | indexRangeBounds: indexRangeUnbounded,
67 | count: 100,
68 | order: "desc",
69 | },
70 | syncSchema,
71 | localStore,
72 | );
73 | const result = paginator.tryFulfill();
74 | expect(result.state).toEqual("fulfilled");
75 |
76 | const results =
77 | result.state === "fulfilled" ? result.results.map((r) => r.emoji) : [];
78 | expect(results).toEqual(["A"]);
79 |
80 | const writesB = new Writes();
81 | writesB.set("conversations", "2" as any, {
82 | _id: "2",
83 | latestMessageTime: 2,
84 | hasUnreadMessages: true,
85 | emoji: "B",
86 | users: [],
87 | });
88 | localStore.applyWrites(writesB);
89 | const paginatorB = new SingleIndexRangeExecutor(
90 | {
91 | tableName: "conversations",
92 | indexName: "by_priority",
93 | indexRangeBounds: indexRangeUnbounded,
94 | count: 100,
95 | order: "desc",
96 | },
97 | syncSchema,
98 | localStore,
99 | );
100 | const resultB = paginatorB.tryFulfill();
101 | expect(resultB.state).toEqual("fulfilled");
102 | const resultsB =
103 | resultB.state === "fulfilled" ? resultB.results.map((r) => r.emoji) : [];
104 | expect(resultsB).toEqual(["B", "A"]);
105 | });
106 |
--------------------------------------------------------------------------------
/curvilinear/convex/issues.ts:
--------------------------------------------------------------------------------
1 | import { v } from "convex/values";
2 | import { DatabaseReader, mutation } from "./_generated/server";
3 |
4 | export const createIssue = mutation({
5 | args: {
6 | id: v.string(),
7 | title: v.string(),
8 | description: v.string(),
9 | priority: v.string(),
10 | status: v.string(),
11 | modified: v.number(),
12 | created: v.number(),
13 | username: v.string(),
14 | },
15 | handler: async (ctx, args) => {
16 | const identity = await ctx.auth.getUserIdentity();
17 | if (!identity) {
18 | throw new Error("Not logged in.");
19 | }
20 | if (identity.name !== args.username) {
21 | throw new Error("Username does not match logged in user.");
22 | }
23 | await ctx.db.insert("issues", args);
24 | },
25 | });
26 |
27 | export const changeStatus = mutation({
28 | args: {
29 | id: v.string(),
30 | status: v.string(),
31 | },
32 | handler: async (ctx, args) => {
33 | const identity = await ctx.auth.getUserIdentity();
34 | if (!identity) {
35 | throw new Error("Not logged in.");
36 | }
37 | const issue = await getIssue(ctx.db, args.id);
38 | return ctx.db.patch(issue._id, {
39 | status: args.status,
40 | modified: Date.now(),
41 | });
42 | },
43 | });
44 |
45 | export const changePriority = mutation({
46 | args: {
47 | id: v.string(),
48 | priority: v.string(),
49 | },
50 | handler: async (ctx, args) => {
51 | const identity = await ctx.auth.getUserIdentity();
52 | if (!identity) {
53 | throw new Error("Not logged in.");
54 | }
55 | const issue = await getIssue(ctx.db, args.id);
56 | return ctx.db.patch(issue._id, {
57 | priority: args.priority,
58 | modified: Date.now(),
59 | });
60 | },
61 | });
62 |
63 | export const changeTitle = mutation({
64 | args: {
65 | id: v.string(),
66 | title: v.string(),
67 | },
68 | handler: async (ctx, args) => {
69 | const identity = await ctx.auth.getUserIdentity();
70 | if (!identity) {
71 | throw new Error("Not logged in.");
72 | }
73 | const issue = await getIssue(ctx.db, args.id);
74 | return ctx.db.patch(issue._id, { title: args.title, modified: Date.now() });
75 | },
76 | });
77 |
78 | export const changeDescription = mutation({
79 | args: {
80 | id: v.string(),
81 | description: v.string(),
82 | },
83 | handler: async (ctx, args) => {
84 | const identity = await ctx.auth.getUserIdentity();
85 | if (!identity) {
86 | throw new Error("Not logged in.");
87 | }
88 | const issue = await getIssue(ctx.db, args.id);
89 | return ctx.db.patch(issue._id, {
90 | description: args.description,
91 | modified: Date.now(),
92 | });
93 | },
94 | });
95 |
96 | export const deleteIssue = mutation({
97 | args: {
98 | id: v.string(),
99 | },
100 | handler: async (ctx, args) => {
101 | const identity = await ctx.auth.getUserIdentity();
102 | if (!identity) {
103 | throw new Error("Not logged in.");
104 | }
105 | const issue = await getIssue(ctx.db, args.id);
106 | await ctx.db.delete(issue._id);
107 | },
108 | });
109 |
110 | async function maybeGetIssue(db: DatabaseReader, id: string) {
111 | return db
112 | .query("issues")
113 | .withIndex("by_issue_id", (q) => q.eq("id", id))
114 | .unique();
115 | }
116 |
117 | async function getIssue(db: DatabaseReader, id: string) {
118 | const issue = await maybeGetIssue(db, id);
119 | if (!issue) {
120 | throw new Error("Issue not found");
121 | }
122 | return issue;
123 | }
124 |
--------------------------------------------------------------------------------
/curvilinear/src/pages/Issue/Comments.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import ReactMarkdown from "react-markdown";
3 | import Editor from "../../components/editor/Editor";
4 | import Avatar from "../../components/Avatar";
5 | import { formatDate } from "../../utils/date";
6 | import { showWarning } from "../../utils/notification";
7 | import { Comment, Issue } from "../../types/types";
8 | import { useLocalStoreClient } from "local-store/react/LocalStoreProvider";
9 | import { loadComments } from "@/local/queries";
10 | import { useLocalQuery } from "local-store/react/hooks";
11 | import { postComment } from "@/local/mutations";
12 | import { useCachedUser } from "@/hooks/useUser";
13 |
14 | export interface CommentsProps {
15 | issue: Issue;
16 | }
17 |
18 | function Comments(commentProps: CommentsProps) {
19 | const [newCommentBody, setNewCommentBody] = useState(``);
20 | const user = useCachedUser();
21 |
22 | const comments: Comment[] | undefined = useLocalQuery(loadComments, {
23 | issue_id: commentProps.issue.id,
24 | });
25 |
26 | const client = useLocalStoreClient();
27 |
28 | const commentList = () => {
29 | if (comments && comments.length > 0) {
30 | return comments.map((comment) => (
31 |
35 |
36 |
37 |
38 | {comment.username}
39 |
40 |
41 | {formatDate(new Date(comment.created_at))}
42 |
43 |
44 |
45 | {comment.body}
46 |
47 |
48 | ));
49 | }
50 | };
51 |
52 | const handlePost = async (event: any) => {
53 | event.preventDefault();
54 | if (!newCommentBody) {
55 | showWarning(
56 | `Please enter a comment before submitting`,
57 | `Comment required`,
58 | );
59 | return;
60 | }
61 | if (!user || !user.fullName) {
62 | showWarning(`Please login to post a comment`, `Login required`);
63 | return;
64 | }
65 | const args = {
66 | id: crypto.randomUUID(),
67 | issue_id: commentProps.issue.id,
68 | body: newCommentBody,
69 | created_at: Date.now(),
70 | username: user.fullName,
71 | };
72 | const promise = client.mutation(postComment, args);
73 | setNewCommentBody("");
74 | await promise;
75 | };
76 |
77 | return (
78 | <>
79 | {commentList()}
80 |
99 | >
100 | );
101 | }
102 |
103 | export default Comments;
104 |
--------------------------------------------------------------------------------
/local-store/browser/driver.ts:
--------------------------------------------------------------------------------
1 | import { CoreSyncEngine } from "./core/core";
2 | import {
3 | CoreRequest,
4 | CoreResponse,
5 | UINewSyncQuery,
6 | UITransition,
7 | } from "./core/protocol";
8 | import { LocalPersistence } from "./localPersistence";
9 | import { Logger } from "./logger";
10 | import { Network } from "./network";
11 |
12 | export class Driver {
13 | coreLocalStore: CoreSyncEngine;
14 | network: Network;
15 | localPersistence: LocalPersistence;
16 | uiTransitionHandler: ((transition: UITransition) => void) | null = null;
17 | newSyncQueryResultHandler: ((result: UINewSyncQuery) => void) | null = null;
18 | requestQueue: CoreRequest[] = [];
19 | logger: Logger;
20 | constructor(opts: {
21 | coreLocalStore: CoreSyncEngine;
22 | network: Network;
23 | localPersistence: LocalPersistence;
24 | logger: Logger;
25 | }) {
26 | this.coreLocalStore = opts.coreLocalStore;
27 | this.network = opts.network;
28 | this.localPersistence = opts.localPersistence;
29 | this.logger = opts.logger;
30 | }
31 |
32 | step() {
33 | for (let i = 0; i < 1000; i++) {
34 | const requests = [...this.requestQueue];
35 | this.requestQueue = [];
36 | for (const request of requests) {
37 | this.logger.debug("Driver.step: processing request", request);
38 | const responses = this.coreLocalStore.receive(request);
39 | for (const response of responses) {
40 | this.processResponse(response);
41 | }
42 | }
43 | if (this.requestQueue.length === 0) {
44 | return;
45 | }
46 | this.logger.warn(
47 | "Driver.step: Processing responses added more requests.",
48 | );
49 | }
50 | throw new Error("Too many steps on a single Driver turn!");
51 | }
52 |
53 | receive(message: CoreRequest) {
54 | this.requestQueue.push(message);
55 | this.step();
56 | }
57 |
58 | addUiTransitionHandler(handler: (transition: UITransition) => void) {
59 | this.uiTransitionHandler = handler;
60 | }
61 |
62 | addNewSyncQueryResultHandler(handler: (result: UINewSyncQuery) => void) {
63 | this.newSyncQueryResultHandler = handler;
64 | }
65 |
66 | private processResponse(response: CoreResponse) {
67 | this.logger.debug("Driver.processResponse: processing", response);
68 | switch (response.kind) {
69 | case "newSyncQuery": {
70 | this.newSyncQueryResultHandler?.(response);
71 | return;
72 | }
73 | case "sendQueryToNetwork": {
74 | this.network.sendQueryToNetwork(
75 | response.syncFunction,
76 | response.pageRequest,
77 | );
78 | return;
79 | }
80 | case "persistMutation": {
81 | this.localPersistence.persistMutation(
82 | response.persistId,
83 | response.mutationInfo,
84 | );
85 | return;
86 | }
87 | case "persistPages": {
88 | this.localPersistence.persistPages(response.persistId, response.pages);
89 | return;
90 | }
91 | case "transition": {
92 | if (!this.uiTransitionHandler) {
93 | throw new Error("No UI transition handler");
94 | }
95 | this.uiTransitionHandler(response);
96 | return;
97 | }
98 | case "removeQueryFromNetwork":
99 | this.network.removeQueryFromNetwork(response.queriesToRemove);
100 | return;
101 | case "sendMutationToNetwork":
102 | this.network.sendMutationToNetwork(response.mutationInfo);
103 | return;
104 | }
105 | const _typecheck: never = response;
106 | throw new Error("Unreachable");
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/curvilinear/src/components/ProfileMenu.tsx:
--------------------------------------------------------------------------------
1 | import { Transition } from "@headlessui/react";
2 | import { useRef, useState } from "react";
3 | import classnames from "classnames";
4 | import { useClickOutside } from "../hooks/useClickOutside";
5 | import Toggle from "./Toggle";
6 | import { SignOutButton } from "@clerk/clerk-react";
7 | import Modal from "./Modal";
8 |
9 | interface Props {
10 | isOpen: boolean;
11 | onDismiss?: () => void;
12 | setShowAboutModal?: (show: boolean) => void;
13 | setShowDebugView?: (show: boolean) => void;
14 | className?: string;
15 | }
16 | export default function ProfileMenu({
17 | isOpen,
18 | className,
19 | onDismiss,
20 | setShowAboutModal,
21 | setShowDebugView,
22 | }: Props) {
23 | const connectivityState = { status: `disconnected` };
24 | const classes = classnames(
25 | `select-none w-53 shadow-modal z-50 flex flex-col py-1 bg-white font-normal rounded text-gray-800`,
26 | className,
27 | );
28 | const ref = useRef(null);
29 |
30 | const connectivityConnected = connectivityState.status !== `disconnected`;
31 | const connectivityStateDisplay =
32 | connectivityState.status[0].toUpperCase() +
33 | connectivityState.status.slice(1);
34 |
35 | useClickOutside(ref, () => {
36 | if (isOpen && onDismiss) {
37 | onDismiss();
38 | }
39 | });
40 |
41 | return (
42 |
43 |
54 |
63 |
67 | Visit Convex
68 |
69 |
73 | Documentation
74 |
75 |
79 | GitHub
80 |
81 |
90 |
91 |
92 |
93 |
94 | {/* XXX: Connectivity tester */}
95 | {/*
96 |
97 | {connectivityStateDisplay}
98 |
99 |
105 |
*/}
106 |
107 |
108 | );
109 | }
110 |
--------------------------------------------------------------------------------
/local-store/browser/core/optimisticUpdateExecutor.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DefaultFunctionArgs,
3 | FunctionReference,
4 | SchemaDefinition,
5 | } from "convex/server";
6 | import { IndexRangeRequest, MutationInfo, TableName } from "../../shared/types";
7 | import { Writes } from "./protocol";
8 | import { LoadingError } from "../localDbReader";
9 | import { GenericId, Value } from "convex/values";
10 | import { LocalDbWriterImpl } from "../localDbWriter";
11 | import { CopyOnWriteLocalStore } from "./localStore";
12 | import { SingleIndexRangeExecutor } from "./paginator";
13 | import { LocalDbWriter } from "../../react/localDb";
14 |
15 | export type SyncMutation = (ctx: { localDb: any }, args: any) => Value;
16 |
17 | export type MutationMap = Record<
18 | string,
19 | {
20 | fn: FunctionReference<"mutation">;
21 | optimisticUpdate?: (
22 | ctx: { localDb: LocalDbWriter },
23 | args: DefaultFunctionArgs,
24 | ) => void;
25 | }
26 | >;
27 |
28 | export function executeSyncMutation(
29 | syncSchema: SchemaDefinition,
30 | mutationMap: MutationMap,
31 | mutationInfo: MutationInfo,
32 | localStore: CopyOnWriteLocalStore,
33 | ): {
34 | result: "loading" | "success" | "error";
35 | localStore: CopyOnWriteLocalStore;
36 | } {
37 | let syncMutationResult: "loading" | "success" | "error" | null = null;
38 | const syncMutation = mutationMap[mutationInfo.mutationName];
39 | if (syncMutation === undefined) {
40 | console.error("Sync mutation not found");
41 | return {
42 | result: "error",
43 | localStore,
44 | };
45 | }
46 | const optimisticUpdate = syncMutation.optimisticUpdate;
47 | if (optimisticUpdate === undefined) {
48 | console.warn("Optimistic update for sync mutation not found");
49 | return {
50 | result: "success",
51 | localStore,
52 | };
53 | }
54 |
55 | const handleRangeRequest = (rangeRequest: IndexRangeRequest) => {
56 | const singleIndexRangeExecutor = new SingleIndexRangeExecutor(
57 | rangeRequest,
58 | syncSchema,
59 | localStoreClone,
60 | );
61 | const result = singleIndexRangeExecutor.tryFulfill();
62 | switch (result.state) {
63 | case "fulfilled":
64 | return result.results;
65 | case "waitingOnLoadingPage": {
66 | syncMutationResult = "loading";
67 | throw new LoadingError();
68 | }
69 | case "needsMorePages": {
70 | syncMutationResult = "loading";
71 | throw new LoadingError();
72 | }
73 | }
74 | };
75 |
76 | const loadObject = (tableName: TableName, id: GenericId) => {
77 | const result = localStore.loadObject(tableName, id);
78 | if (result === undefined) {
79 | syncMutationResult = "loading";
80 | throw new LoadingError();
81 | }
82 | return result;
83 | };
84 |
85 | const localStoreClone = localStore.clone();
86 | const writes = new Writes();
87 | const localDb = new LocalDbWriterImpl(
88 | syncSchema,
89 | handleRangeRequest,
90 | loadObject,
91 | (tableName, id, doc) => {
92 | writes.set(tableName, id, doc);
93 | localStoreClone.applyWrites(writes);
94 | },
95 | );
96 | try {
97 | optimisticUpdate({ localDb: localDb as any }, mutationInfo.optUpdateArgs);
98 | if (syncMutationResult === null) {
99 | syncMutationResult = "success";
100 | }
101 | } catch (e) {
102 | if (e instanceof LoadingError) {
103 | syncMutationResult = "loading";
104 | } else {
105 | syncMutationResult = "error";
106 | }
107 | }
108 | return {
109 | result: syncMutationResult,
110 | localStore: syncMutationResult === "success" ? localStoreClone : localStore,
111 | };
112 | }
113 |
--------------------------------------------------------------------------------
/curvilinear/convex/_generated/server.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import {
12 | actionGeneric,
13 | httpActionGeneric,
14 | queryGeneric,
15 | mutationGeneric,
16 | internalActionGeneric,
17 | internalMutationGeneric,
18 | internalQueryGeneric,
19 | } from "convex/server";
20 |
21 | /**
22 | * Define a query in this Convex app's public API.
23 | *
24 | * This function will be allowed to read your Convex database and will be accessible from the client.
25 | *
26 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
27 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
28 | */
29 | export const query = queryGeneric;
30 |
31 | /**
32 | * Define a query that is only accessible from other Convex functions (but not from the client).
33 | *
34 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
35 | *
36 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
37 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
38 | */
39 | export const internalQuery = internalQueryGeneric;
40 |
41 | /**
42 | * Define a mutation in this Convex app's public API.
43 | *
44 | * This function will be allowed to modify your Convex database and will be accessible from the client.
45 | *
46 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
47 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
48 | */
49 | export const mutation = mutationGeneric;
50 |
51 | /**
52 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
53 | *
54 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
55 | *
56 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
57 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
58 | */
59 | export const internalMutation = internalMutationGeneric;
60 |
61 | /**
62 | * Define an action in this Convex app's public API.
63 | *
64 | * An action is a function which can execute any JavaScript code, including non-deterministic
65 | * code and code with side-effects, like calling third-party services.
66 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
67 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
68 | *
69 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
70 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
71 | */
72 | export const action = actionGeneric;
73 |
74 | /**
75 | * Define an action that is only accessible from other Convex functions (but not from the client).
76 | *
77 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
78 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
79 | */
80 | export const internalAction = internalActionGeneric;
81 |
82 | /**
83 | * Define a Convex HTTP action.
84 | *
85 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
86 | * as its second.
87 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
88 | */
89 | export const httpAction = httpActionGeneric;
90 |
--------------------------------------------------------------------------------
/local-store/react/LocalStoreProvider.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, ReactNode, useContext } from "react";
2 | import { LocalStoreClient } from "../browser/ui";
3 | import { SchemaDefinition } from "convex/server";
4 | import { BaseConvexClient } from "convex/browser";
5 | import { Driver } from "../browser/driver";
6 | import { Logger } from "../browser/logger";
7 | import { CoreSyncEngine } from "../browser/core/core";
8 | import { NetworkImpl } from "../browser/network";
9 | import {
10 | LocalPersistence,
11 | NoopLocalPersistence,
12 | } from "../browser/localPersistence";
13 | import { MutationRegistry } from "./mutationRegistry";
14 | import { ConvexReactClient } from "convex/react";
15 | import { Election } from "../browser/worker/election";
16 | export const LocalStoreContext = createContext(null);
17 |
18 | type LocalStoreProviderProps> =
19 | | {
20 | children: ReactNode;
21 |
22 | client: BaseConvexClient;
23 | syncSchema: SyncSchema;
24 | persistence?: LocalPersistence;
25 | mutations: MutationRegistry;
26 | }
27 | | {
28 | children: ReactNode;
29 |
30 | localStoreClient: LocalStoreClient;
31 | };
32 |
33 | export function LocalStoreProvider<
34 | SyncSchema extends SchemaDefinition,
35 | >(props: LocalStoreProviderProps) {
36 | let localStoreClient: LocalStoreClient;
37 | if ("localStoreClient" in props) {
38 | localStoreClient = props.localStoreClient;
39 | } else {
40 | const { client, syncSchema, mutations, persistence } = props;
41 | const logger = new Logger();
42 | const mutationMap = mutations.exportToMutationMap();
43 | const coreLocalStore = new CoreSyncEngine(syncSchema, mutationMap, logger);
44 | const driver = new Driver({
45 | coreLocalStore,
46 | network: new NetworkImpl({ convexClient: client }),
47 | localPersistence: persistence ?? new NoopLocalPersistence(),
48 | logger,
49 | });
50 | localStoreClient = new LocalStoreClient({
51 | driver,
52 | syncSchema,
53 | mutations: mutationMap,
54 | });
55 | }
56 | (globalThis as any).localDb = localStoreClient;
57 | return (
58 |
59 | {props.children}
60 |
61 | );
62 | }
63 |
64 | export function useLocalStoreClient(): LocalStoreClient {
65 | const localStoreClient = useContext(LocalStoreContext);
66 | if (localStoreClient === null) {
67 | throw new Error(
68 | "useLocalStoreClient must be used within a LocalStoreProvider",
69 | );
70 | }
71 | return localStoreClient;
72 | }
73 |
74 | export function createLocalStoreClient(opts: {
75 | syncSchema: SchemaDefinition;
76 | mutationRegistry: MutationRegistry;
77 | convexClient: ConvexReactClient;
78 | convexUrl: string;
79 | persistenceKey: string | null;
80 | }) {
81 | const persistence = opts.persistenceKey
82 | ? new Election(opts.persistenceKey, opts.convexUrl)
83 | : new NoopLocalPersistence();
84 | const logger = new Logger();
85 | const mutationMap = opts.mutationRegistry.exportToMutationMap();
86 | const coreLocalStore = new CoreSyncEngine(
87 | opts.syncSchema,
88 | mutationMap,
89 | logger,
90 | );
91 | const driver = new Driver({
92 | coreLocalStore,
93 | network: new NetworkImpl({ convexClient: opts.convexClient.sync }),
94 | localPersistence: persistence ?? new NoopLocalPersistence(),
95 | logger,
96 | });
97 | const localStore = new LocalStoreClient({
98 | driver,
99 | syncSchema: opts.syncSchema,
100 | mutations: mutationMap,
101 | });
102 | return localStore;
103 | }
104 |
--------------------------------------------------------------------------------
/curvilinear/src/components/contextmenu/FilterMenu.tsx:
--------------------------------------------------------------------------------
1 | import { Portal } from "../Portal";
2 | import { ReactNode, useState } from "react";
3 | import { ContextMenuTrigger } from "@firefox-devtools/react-contextmenu";
4 | import { BsCheck2 } from "react-icons/bs";
5 | import { Menu } from "./menu";
6 | import { useFilterState } from "../../utils/filterState";
7 | import { PriorityOptions, StatusOptions } from "../../types/types";
8 |
9 | interface Props {
10 | id: string;
11 | button: ReactNode;
12 | className?: string;
13 | }
14 |
15 | function FilterMenu({ id, button, className }: Props) {
16 | const [filterState, setFilterState] = useFilterState();
17 | const [keyword, setKeyword] = useState(``);
18 |
19 | let priorities = PriorityOptions;
20 | if (keyword !== ``) {
21 | const normalizedKeyword = keyword.toLowerCase().trim();
22 | priorities = priorities.filter(
23 | ([_icon, _priority, label]) =>
24 | (label as string).toLowerCase().indexOf(normalizedKeyword) !== -1,
25 | );
26 | }
27 |
28 | let statuses = StatusOptions;
29 | if (keyword !== ``) {
30 | const normalizedKeyword = keyword.toLowerCase().trim();
31 | statuses = statuses.filter(
32 | ([_icon, _status, label]) =>
33 | label.toLowerCase().indexOf(normalizedKeyword) !== -1,
34 | );
35 | }
36 |
37 | const priorityOptions = priorities.map(([svg, priority, label], idx) => {
38 | return (
39 | handlePrioritySelect(priority as string)}
42 | >
43 |
44 | {label}
45 | {filterState.priority?.includes(priority) && (
46 |
47 | )}
48 |
49 | );
50 | });
51 |
52 | const statusOptions = statuses.map(([svg, status, label], idx) => {
53 | return (
54 | handleStatusSelect(status as string)}
57 | >
58 |
59 | {label}
60 | {filterState.status?.includes(status) && (
61 |
62 | )}
63 |
64 | );
65 | });
66 |
67 | const handlePrioritySelect = (priority: string) => {
68 | setKeyword(``);
69 | const newPriority = filterState.priority || [];
70 | if (newPriority.includes(priority)) {
71 | newPriority.splice(newPriority.indexOf(priority), 1);
72 | } else {
73 | newPriority.push(priority);
74 | }
75 | setFilterState({
76 | ...filterState,
77 | priority: newPriority,
78 | });
79 | };
80 |
81 | const handleStatusSelect = (status: string) => {
82 | setKeyword(``);
83 | const newStatus = filterState.status || [];
84 | if (newStatus.includes(status)) {
85 | newStatus.splice(newStatus.indexOf(status), 1);
86 | } else {
87 | newStatus.push(status);
88 | }
89 | setFilterState({
90 | ...filterState,
91 | status: newStatus,
92 | });
93 | };
94 |
95 | return (
96 | <>
97 |
98 | {button}
99 |
100 |
101 |
102 |
116 |
117 | >
118 | );
119 | }
120 |
121 | export default FilterMenu;
122 |
--------------------------------------------------------------------------------
/curvilinear/src/components/editor/EditorMenu.tsx:
--------------------------------------------------------------------------------
1 | import type { Editor as TipTapEditor } from "@tiptap/react";
2 | import classNames from "classnames";
3 |
4 | import { BsTypeBold as BoldIcon } from "react-icons/bs";
5 | import { BsTypeItalic as ItalicIcon } from "react-icons/bs";
6 | import { BsTypeStrikethrough as StrikeIcon } from "react-icons/bs";
7 | import { BsCode as CodeIcon } from "react-icons/bs";
8 | import { BsListUl as BulletListIcon } from "react-icons/bs";
9 | import { BsListOl as OrderedListIcon } from "react-icons/bs";
10 | import { BsCodeSlash as CodeBlockIcon } from "react-icons/bs";
11 | import { BsChatQuote as BlockquoteIcon } from "react-icons/bs";
12 |
13 | export interface EditorMenuProps {
14 | editor: TipTapEditor;
15 | }
16 |
17 | const EditorMenu = ({ editor }: EditorMenuProps) => {
18 | return (
19 |
20 |
32 |
44 |
56 |
68 |
69 |
80 |
91 |
102 |
113 |
114 | );
115 | };
116 |
117 | export default EditorMenu;
118 |
--------------------------------------------------------------------------------
/local-store/browser/localDbReader.ts:
--------------------------------------------------------------------------------
1 | import { GenericDocument, IndexRange } from "convex/server";
2 | import { DocumentByInfo, NamedIndex } from "convex/server";
3 | import { GenericTableInfo } from "convex/server";
4 | import { IndexRangeBuilder } from "convex/server";
5 | import { IndexName, IndexPrefix, IndexRangeRequest } from "../shared/types";
6 |
7 | import { IndexRangeBounds, TableName } from "../shared/types";
8 | import { PaginatorIndexRange } from "../shared/pagination";
9 | import { GenericId } from "convex/values";
10 | import { indexFieldsForSyncObject } from "../server/resolvers";
11 |
12 | export class LocalDbReaderImpl {
13 | public debugIndexRanges: Map<
14 | string,
15 | {
16 | table: string;
17 | index: string;
18 | indexRangeBounds: IndexRangeBounds;
19 | order: "asc" | "desc";
20 | limit: number;
21 | }
22 | > = new Map();
23 | constructor(
24 | private syncSchema: any,
25 | private requestRange: (
26 | rangeRequest: IndexRangeRequest,
27 | ) => Array,
28 | private loadObject: (
29 | table: TableName,
30 | id: GenericId,
31 | ) => GenericDocument | null,
32 | ) {}
33 |
34 | query(table: TableName) {
35 | return {
36 | fullTableScan: () => {
37 | throw new Error("Not implemented");
38 | },
39 | withIndex: (
40 | indexName: IndexName,
41 | indexBuilder?: (
42 | q: IndexRangeBuilder<
43 | DocumentByInfo,
44 | NamedIndex
45 | >,
46 | ) => IndexRange,
47 | ) => {
48 | return {
49 | collect: () => this.queryRange(table, indexName, indexBuilder, "asc"),
50 | take: (count: number) =>
51 | this.queryRange(table, indexName, indexBuilder, "asc", count),
52 | order: (order: "asc" | "desc") => {
53 | return {
54 | collect: () =>
55 | this.queryRange(
56 | table,
57 | indexName,
58 | indexBuilder,
59 | order,
60 | Number.POSITIVE_INFINITY,
61 | ),
62 | take: (count: number) =>
63 | this.queryRange(table, indexName, indexBuilder, order, count),
64 | };
65 | },
66 | };
67 | },
68 | withSearchIndex: (_indexName: any, _indexBuilder: any) => {
69 | throw new Error("Not implemented");
70 | },
71 | };
72 | }
73 |
74 | get(table: TableName, id: GenericId) {
75 | return this.loadObject(table, id);
76 | }
77 |
78 | private queryRange(
79 | table: TableName,
80 | index: IndexName,
81 | indexRange?: (
82 | q: IndexRangeBuilder<
83 | DocumentByInfo,
84 | NamedIndex
85 | >,
86 | ) => IndexRange,
87 | order: "asc" | "desc" = "asc",
88 | count: number = Number.POSITIVE_INFINITY,
89 | ): GenericDocument[] {
90 | console.log("queryRange", table, index, order, count);
91 | // TODO: should do something like this
92 | // or even better, use the DatabaseImpl wholesale from convex/server
93 | // const indexRangeJson = (indexRange(IndexRangeBuilderImpl.new()) as any).export()
94 | // But that's not exported from convex/server yet.
95 | // So we do the hack using the version in convex-helpers.
96 | const indexFields = indexFieldsForSyncObject(this.syncSchema, table, index);
97 | const paginatorIndexRange = new PaginatorIndexRange(indexFields);
98 | if (indexRange) {
99 | indexRange(paginatorIndexRange as any);
100 | }
101 | const indexRangeBounds: IndexRangeBounds = {
102 | lowerBound:
103 | paginatorIndexRange.lowerBoundIndexKey ??
104 | ([] as unknown as IndexPrefix),
105 | lowerBoundInclusive: paginatorIndexRange.lowerBoundInclusive,
106 | upperBound:
107 | paginatorIndexRange.upperBoundIndexKey ??
108 | ([] as unknown as IndexPrefix),
109 | upperBoundInclusive: paginatorIndexRange.upperBoundInclusive,
110 | };
111 | // requestRange will cancel the query execution if the result is not loaded,
112 | // and make the sync query return loading
113 | return this.requestRange({
114 | tableName: table,
115 | indexName: index,
116 | count,
117 | indexRangeBounds,
118 | order,
119 | });
120 | }
121 | }
122 |
123 | export class LoadingError extends Error {
124 | constructor() {
125 | super("Index range is loading in sync query");
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/local-store/browser/core/syncQueryExecutor.ts:
--------------------------------------------------------------------------------
1 | import { anyApi } from "convex/server";
2 | import {
3 | IndexRangeRequest,
4 | SyncQueryResult,
5 | ConvexSubscriptionId,
6 | TableName,
7 | IndexName,
8 | PageArguments,
9 | } from "../../shared/types";
10 | import { CoreResponse } from "./protocol";
11 | import { LoadingError, LocalDbReaderImpl } from "../localDbReader";
12 | import { GenericId, Value } from "convex/values";
13 | import { CopyOnWriteLocalStore } from "./localStore";
14 | import { LOG2_PAGE_SIZE, SingleIndexRangeExecutor } from "./paginator";
15 | import { createQueryToken } from "../../shared/queryTokens";
16 |
17 | export type SyncQuery = (ctx: { localDb: any }, args: any) => Value;
18 |
19 | export type SyncQueryExecutionResult = {
20 | responses: CoreResponse[];
21 | pagesRead: ConvexSubscriptionId[];
22 | newPage: {
23 | subscriptionId: ConvexSubscriptionId;
24 | args: PageArguments;
25 | } | null;
26 | result: SyncQueryResult;
27 | };
28 |
29 | function getSyncFunction(tableName: TableName, indexName: IndexName) {
30 | return anyApi.sync[tableName][indexName];
31 | }
32 |
33 | export function executeSyncQuery(
34 | syncSchema: any,
35 | syncQueryFn: SyncQuery,
36 | syncQueryArgs: any,
37 | localStore: CopyOnWriteLocalStore,
38 | ): SyncQueryExecutionResult {
39 | let syncQueryResult: SyncQueryResult | undefined;
40 | let newPage: {
41 | subscriptionId: ConvexSubscriptionId;
42 | args: PageArguments;
43 | } | null = null;
44 | const responses: CoreResponse[] = [];
45 | const pagesRead: Set = new Set();
46 |
47 | const handleRangeRequest = (rangeRequest: IndexRangeRequest) => {
48 | const singleIndexRangeExecutor = new SingleIndexRangeExecutor(
49 | rangeRequest,
50 | syncSchema,
51 | localStore,
52 | );
53 |
54 | const result = singleIndexRangeExecutor.tryFulfill();
55 | switch (result.state) {
56 | case "fulfilled":
57 | for (const pageSubscriptionId of result.pageSubscriptionIds) {
58 | pagesRead.add(pageSubscriptionId);
59 | }
60 | return result.results;
61 | case "waitingOnLoadingPage": {
62 | for (const pageSubscriptionId of result.loadingPageSubscriptionIds) {
63 | pagesRead.add(pageSubscriptionId);
64 | }
65 | syncQueryResult = { kind: "loading" };
66 | throw new LoadingError();
67 | }
68 | case "needsMorePages": {
69 | result.existingPageSubscriptionIds.forEach((id) => {
70 | pagesRead.add(id);
71 | });
72 | const pageArgs = {
73 | syncTableName: rangeRequest.tableName,
74 | index: rangeRequest.indexName,
75 | target: result.targetKey,
76 | log2PageSize: LOG2_PAGE_SIZE,
77 | };
78 | const pageSubscriptionId = createQueryToken(
79 | getSyncFunction(rangeRequest.tableName, rangeRequest.indexName),
80 | pageArgs,
81 | );
82 | newPage = { subscriptionId: pageSubscriptionId, args: pageArgs };
83 | pagesRead.add(pageSubscriptionId);
84 | responses.push({
85 | recipient: "Network",
86 | kind: "sendQueryToNetwork",
87 | syncFunction: getSyncFunction(
88 | rangeRequest.tableName,
89 | rangeRequest.indexName,
90 | ),
91 | pageRequest: pageArgs,
92 | });
93 | syncQueryResult = { kind: "loading" };
94 | throw new LoadingError();
95 | }
96 | }
97 | };
98 |
99 | const loadObject = (tableName: TableName, id: GenericId) => {
100 | const result = localStore.loadObject(tableName, id);
101 | if (result === undefined) {
102 | syncQueryResult = { kind: "loading" };
103 | throw new LoadingError();
104 | }
105 | return result;
106 | };
107 |
108 | const localDb = new LocalDbReaderImpl(
109 | syncSchema,
110 | handleRangeRequest,
111 | loadObject,
112 | );
113 | try {
114 | const result = syncQueryFn({ localDb }, syncQueryArgs);
115 | if (result instanceof Promise) {
116 | syncQueryResult = {
117 | kind: "loaded",
118 | status: "error",
119 | error: new Error("Sync query returned a promise"),
120 | };
121 | }
122 | if (syncQueryResult === undefined) {
123 | syncQueryResult = { kind: "loaded", status: "success", value: result };
124 | }
125 | } catch (e) {
126 | if (e instanceof LoadingError) {
127 | syncQueryResult = { kind: "loading" };
128 | } else if (syncQueryResult === undefined) {
129 | syncQueryResult = {
130 | kind: "loaded",
131 | status: "error",
132 | error: e,
133 | };
134 | }
135 | }
136 | return {
137 | responses,
138 | pagesRead: Array.from(pagesRead),
139 | newPage,
140 | result: syncQueryResult,
141 | };
142 | }
143 |
--------------------------------------------------------------------------------
/local-store/shared/types.ts:
--------------------------------------------------------------------------------
1 | import { QueryToken } from "convex/browser";
2 | import {
3 | DefaultFunctionArgs,
4 | FunctionReference,
5 | GenericDocument,
6 | } from "convex/server";
7 | import { Value } from "convex/values";
8 | import { assert } from "./assert";
9 |
10 | export type ConvexSubscriptionId = QueryToken & {
11 | __brand: "ConvexSubscriptionId";
12 | };
13 |
14 | export type SyncQuerySubscriptionId = string & {
15 | __brand: "SyncQuerySubscriptionId";
16 | };
17 |
18 | export type SyncQueryResult =
19 | | {
20 | kind: "loaded";
21 | status: "success";
22 | value: Value;
23 | }
24 | | {
25 | kind: "loaded";
26 | status: "error";
27 | error: any;
28 | }
29 | | { kind: "loading" };
30 |
31 | export type SyncQueryExecutionId = number & { __brand: "SyncQueryExecutionId" };
32 |
33 | export type PersistId = string & { __brand: "PersistId" };
34 |
35 | export type MutationResult =
36 | | {
37 | status: "success";
38 | value: Value;
39 | }
40 | | {
41 | status: "error";
42 | error: any;
43 | };
44 |
45 | export type MutationInfo = {
46 | mutationName: string;
47 | mutationId: MutationId;
48 | mutationPath: FunctionReference<"mutation">;
49 | optUpdateArgs: DefaultFunctionArgs;
50 | serverArgs: DefaultFunctionArgs;
51 | };
52 |
53 | export type ServerStoreVersion = number & { __brand: "ServerStoreVersion" };
54 |
55 | export class LocalStoreVersion {
56 | private version: number;
57 | serverVersion: ServerStoreVersion;
58 | constructor(version: number, serverVersion: ServerStoreVersion) {
59 | this.version = version;
60 | this.serverVersion = serverVersion;
61 | }
62 |
63 | increment(): LocalStoreVersion {
64 | return new LocalStoreVersion(this.version + 1, this.serverVersion);
65 | }
66 |
67 | advanceToServerVersion(serverVersion: ServerStoreVersion): LocalStoreVersion {
68 | assert(
69 | serverVersion > this.serverVersion,
70 | "Cannot advance to a past server version",
71 | );
72 | return new LocalStoreVersion(this.version + 1, serverVersion);
73 | }
74 | }
75 |
76 | export type MutationId = string & { __brand: "MutationId" };
77 |
78 | export type TableName = string;
79 | export type IndexName = string;
80 |
81 | // has all of the fields of the index
82 | export type IndexKey = ReadonlyArray;
83 |
84 | export type IndexPrefix = ReadonlyArray;
85 |
86 | export const MAXIMAL_KEY = {
87 | kind: "successor",
88 | value: [],
89 | } as const;
90 |
91 | export const MINIMAL_KEY = {
92 | kind: "predecessor",
93 | value: [],
94 | } as const;
95 |
96 | export type ExactKey = {
97 | kind: "exact";
98 | value: IndexKey;
99 | };
100 |
101 | export type Key =
102 | | {
103 | kind: "successor" | "predecessor";
104 | value: IndexPrefix;
105 | }
106 | | ExactKey;
107 |
108 | export type PageArguments = {
109 | syncTableName: string;
110 | index: string;
111 | target: Key;
112 | log2PageSize: number;
113 | };
114 |
115 | export type PageResult = {
116 | results: GenericDocument[];
117 | lowerBound: LowerBound;
118 | upperBound: UpperBound;
119 | };
120 |
121 | export type LowerBound =
122 | | { kind: "successor"; value: IndexPrefix }
123 | | typeof MINIMAL_KEY;
124 | export type UpperBound = ExactKey | typeof MAXIMAL_KEY;
125 |
126 | export type IndexRangeBounds = {
127 | lowerBound: IndexPrefix; // [conversationId]
128 | lowerBoundInclusive: boolean;
129 | upperBound: IndexPrefix; // [conversationId]
130 | upperBoundInclusive: boolean;
131 | };
132 |
133 | // Output cursor from the developer-defined generator function.
134 | export type GeneratorCursor =
135 | | typeof MAXIMAL_KEY
136 | | typeof MINIMAL_KEY
137 | | ExactKey;
138 | // Input cursor to the developer-defined generator function.
139 | // In addition to being an index key or minimal/maximal key, it can also be an inclusive or exclusive bound.
140 | export type GeneratorInputCursor = {
141 | key: IndexPrefix;
142 | inclusive: boolean;
143 | };
144 |
145 | export const isMaximal = (c: Key): c is typeof MAXIMAL_KEY => {
146 | return c.kind === "successor" && c.value.length === 0;
147 | };
148 |
149 | export const isMinimal = (c: Key): c is typeof MINIMAL_KEY => {
150 | return c.kind === "predecessor" && c.value.length === 0;
151 | };
152 |
153 | export const isExact = (c: Key): c is ExactKey => {
154 | return c.kind === "exact";
155 | };
156 |
157 | // Corresponds to a `db.query`
158 | export type PaginatorSubscriptionId = string & {
159 | __brand: "PaginatorSubscriptionId";
160 | };
161 |
162 | export type SyncFunction = FunctionReference<
163 | "query",
164 | "public",
165 | PageArguments,
166 | PageResult
167 | >;
168 |
169 | export type SyncGetFunction = FunctionReference<
170 | "query",
171 | "public",
172 | { _id: string },
173 | GenericDocument | null
174 | >;
175 |
176 | export type IndexRangeRequest = {
177 | tableName: TableName;
178 | indexName: IndexName;
179 | count: number;
180 | indexRangeBounds: IndexRangeBounds;
181 | order: "asc" | "desc";
182 | };
183 |
--------------------------------------------------------------------------------
/local-store/browser/core/protocol.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ConvexSubscriptionId,
3 | IndexName,
4 | Key,
5 | MutationInfo,
6 | PageArguments,
7 | PageResult,
8 | PersistId,
9 | TableName,
10 | } from "../../shared/types";
11 |
12 | import { GenericId, convexToJson } from "convex/values";
13 | import {
14 | MutationId,
15 | SyncFunction,
16 | SyncQueryResult,
17 | SyncQuerySubscriptionId,
18 | } from "../../shared/types";
19 | import { DefaultFunctionArgs, GenericDocument } from "convex/server";
20 | import { SyncQuery } from "./syncQueryExecutor";
21 |
22 | export type RangeResponse = Array;
23 |
24 | export type Page = {
25 | tableName: TableName;
26 | indexName: IndexName;
27 | convexSubscriptionId: ConvexSubscriptionId;
28 | state:
29 | | {
30 | kind: "loaded";
31 | value: PageResult;
32 | }
33 | | {
34 | kind: "loading";
35 | target: Key;
36 | };
37 | };
38 |
39 | export class Writes {
40 | writes: Map, GenericDocument | null>>;
41 | constructor() {
42 | this.writes = new Map();
43 | }
44 |
45 | prettyPrint(): string {
46 | let result = "";
47 | for (const [tableName, tableWrites] of this.writes.entries()) {
48 | result += `${tableName}:\n`;
49 | for (const [id, doc] of tableWrites.entries()) {
50 | result += ` ${id}: ${JSON.stringify(convexToJson(doc))}\n`;
51 | }
52 | }
53 | return result;
54 | }
55 |
56 | set(tableName: TableName, id: GenericId, doc: GenericDocument | null) {
57 | if (!this.writes.has(tableName)) {
58 | this.writes.set(tableName, new Map());
59 | }
60 | const tableWrites = this.writes.get(tableName)!;
61 | tableWrites.set(id, doc);
62 | }
63 |
64 | apply(other: Writes) {
65 | for (const [tableName, tableWrites] of other.writes) {
66 | const existingTableWrites = this.writes.get(tableName) ?? new Map();
67 | for (const [id, write] of tableWrites) {
68 | existingTableWrites.set(id, write);
69 | }
70 | this.writes.set(tableName, existingTableWrites);
71 | }
72 | }
73 |
74 | clone(): Writes {
75 | const clone = new Writes();
76 | clone.writes = new Map(
77 | Array.from(this.writes.entries()).map(([tableName, tableWrites]) => [
78 | tableName,
79 | new Map(tableWrites.entries()),
80 | ]),
81 | );
82 | return clone;
83 | }
84 | }
85 |
86 | export type CorePersistenceRequest =
87 | | {
88 | requestor: "LocalPersistence";
89 | kind: "ingestFromLocalPersistence";
90 | pages: Page[];
91 | serverTs: number;
92 | }
93 | | {
94 | requestor: "LocalPersistence";
95 | kind: "localPersistComplete";
96 | persistId: PersistId;
97 | };
98 |
99 | export type CoreRequest =
100 | | {
101 | requestor: "UI";
102 | kind: "addSyncQuerySubscription";
103 | syncQuerySubscriptionId: SyncQuerySubscriptionId;
104 | syncQueryFn: SyncQuery;
105 | syncQueryArgs: DefaultFunctionArgs;
106 | }
107 | | {
108 | requestor: "UI";
109 | kind: "unsubscribeFromSyncQuery";
110 | syncQuerySubscriptionId: SyncQuerySubscriptionId;
111 | }
112 | | {
113 | requestor: "UI";
114 | kind: "mutate";
115 | mutationInfo: MutationInfo;
116 | }
117 | | NetworkTransition
118 | | {
119 | requestor: "Network";
120 | kind: "mutationResponseFromNetwork";
121 | mutationId: string;
122 | result: any;
123 | }
124 | | CorePersistenceRequest;
125 |
126 | export type NetworkTransition = {
127 | requestor: "Network";
128 | kind: "transitionFromNetwork";
129 | serverTs: number;
130 | queryResults: Map<
131 | ConvexSubscriptionId,
132 | | { kind: "success"; result: Page }
133 | | { kind: "error"; errorMessage: string; errorData: any }
134 | >;
135 | reflectedMutations: MutationId[];
136 | };
137 |
138 | export type UITransition = {
139 | recipient: "UI";
140 | kind: "transition";
141 | syncQueryUpdates: Map;
142 | mutationsApplied: Set;
143 | };
144 |
145 | export type UINewSyncQuery = {
146 | recipient: "UI";
147 | kind: "newSyncQuery";
148 | syncQuerySubscriptionId: SyncQuerySubscriptionId;
149 | syncQueryResult: SyncQueryResult;
150 | };
151 |
152 | export type CoreResponse =
153 | | UITransition
154 | | UINewSyncQuery
155 | | {
156 | recipient: "Network";
157 | kind: "sendMutationToNetwork";
158 | mutationInfo: MutationInfo;
159 | }
160 | | {
161 | recipient: "Network";
162 | kind: "sendQueryToNetwork";
163 | syncFunction: SyncFunction;
164 | pageRequest: PageArguments;
165 | }
166 | | {
167 | recipient: "Network";
168 | kind: "removeQueryFromNetwork";
169 | queriesToRemove: ConvexSubscriptionId[];
170 | }
171 | | {
172 | recipient: "LocalPersistence";
173 | kind: "persistPages";
174 | pages: Page[];
175 | persistId: PersistId;
176 | }
177 | | {
178 | recipient: "LocalPersistence";
179 | kind: "persistMutation";
180 | persistId: PersistId;
181 | mutationInfo: MutationInfo;
182 | };
183 |
--------------------------------------------------------------------------------
/local-store/browser/worker/types.ts:
--------------------------------------------------------------------------------
1 | import { convexToJson, jsonToConvex } from "convex/values";
2 | import { z } from "zod";
3 | import {
4 | ConvexSubscriptionId,
5 | LowerBound,
6 | UpperBound,
7 | MutationInfo,
8 | MutationId,
9 | } from "../../shared/types";
10 | import { Page } from "../core/protocol";
11 | import { getFunctionName, makeFunctionReference } from "convex/server";
12 |
13 | // Each instance of the `SyncWorkerClient` class receives a unique instance ID.
14 | export type ClientId = string;
15 |
16 | const storedIndexPrefix = z.array(z.any());
17 | const storedMinimalKey = z.object({
18 | kind: z.literal("predecessor"),
19 | value: z.array(z.never()),
20 | });
21 | const storedLowerBound = z.union([
22 | z.object({ kind: z.literal("successor"), value: storedIndexPrefix }),
23 | storedMinimalKey,
24 | ]);
25 | const storedIndexKey = z.array(z.any());
26 | const storedExactKey = z.object({
27 | kind: z.literal("exact"),
28 | value: storedIndexKey,
29 | });
30 | const storedMaximalKey = z.object({
31 | kind: z.literal("successor"),
32 | value: z.array(z.never()),
33 | });
34 | const storedUpperBound = z.union([storedExactKey, storedMaximalKey]);
35 |
36 | export const storedPageValidator = z.object({
37 | table: z.string(),
38 | index: z.string(),
39 | convexSubscriptionId: z.string(),
40 | serializedLowerBound: z.string(),
41 | lowerBound: storedLowerBound,
42 | upperBound: storedUpperBound,
43 | documents: z.array(z.any()),
44 | });
45 | export type StoredPage = z.infer;
46 |
47 | export function pageToStoredPage(page: Page): StoredPage | null {
48 | if (page.state.kind === "loading") {
49 | return null;
50 | }
51 | return storedPageValidator.parse({
52 | table: page.tableName,
53 | index: page.indexName,
54 | convexSubscriptionId: page.convexSubscriptionId,
55 | serializedLowerBound: JSON.stringify(page.state.value.lowerBound),
56 | lowerBound: page.state.value.lowerBound,
57 | upperBound: page.state.value.upperBound,
58 | documents: page.state.value.results,
59 | });
60 | }
61 |
62 | export function storedPageToPage(storedPage: StoredPage): Page {
63 | return {
64 | tableName: storedPage.table,
65 | indexName: storedPage.index,
66 | convexSubscriptionId:
67 | storedPage.convexSubscriptionId as ConvexSubscriptionId,
68 | state: {
69 | kind: "loaded",
70 | value: {
71 | lowerBound: storedPage.lowerBound as LowerBound,
72 | upperBound: storedPage.upperBound as UpperBound,
73 | results: storedPage.documents,
74 | },
75 | },
76 | };
77 | }
78 |
79 | export const storedMutationValidator = z.object({
80 | mutationName: z.string(),
81 | mutationId: z.string(),
82 | mutationPath: z.string(),
83 | optUpdateArgs: z.any(),
84 | serverArgs: z.any(),
85 | });
86 | export type StoredMutation = z.infer;
87 |
88 | export function mutationToStoredMutation(
89 | mutation: MutationInfo,
90 | ): StoredMutation {
91 | return storedMutationValidator.parse({
92 | mutationName: mutation.mutationName,
93 | mutationId: mutation.mutationId,
94 | mutationPath: getFunctionName(mutation.mutationPath),
95 | optUpdateArgs: convexToJson(mutation.optUpdateArgs as any),
96 | serverArgs: convexToJson(mutation.serverArgs as any),
97 | });
98 | }
99 |
100 | export function storedMutationToMutation(
101 | storedMutation: StoredMutation,
102 | ): MutationInfo {
103 | return {
104 | mutationName: storedMutation.mutationName,
105 | mutationId: storedMutation.mutationId as MutationId,
106 | mutationPath: makeFunctionReference(storedMutation.mutationPath),
107 | optUpdateArgs: jsonToConvex(storedMutation.optUpdateArgs) as any,
108 | serverArgs: jsonToConvex(storedMutation.serverArgs) as any,
109 | };
110 | }
111 |
112 | export const followerMessage = z.discriminatedUnion("type", [
113 | z.object({
114 | type: z.literal("join"),
115 | clientId: z.string(),
116 | name: z.string(),
117 | address: z.string(),
118 | }),
119 | z.object({
120 | type: z.literal("persist"),
121 | clientId: z.string(),
122 | persistId: z.string(),
123 | mutationInfos: z.array(storedMutationValidator),
124 | pages: z.array(storedPageValidator),
125 | }),
126 | ]);
127 | export type FollowerMessage = z.infer;
128 |
129 | export const leaderMessage = z.discriminatedUnion("type", [
130 | z.object({
131 | type: z.literal("joinResult"),
132 | requestingClientId: z.string(),
133 | leaderClientId: z.string(),
134 | result: z.discriminatedUnion("type", [
135 | z.object({
136 | type: z.literal("success"),
137 | pages: z.array(storedPageValidator),
138 | mutations: z.array(z.any()),
139 | }),
140 | z.object({
141 | type: z.literal("failure"),
142 | error: z.string(),
143 | }),
144 | ]),
145 | }),
146 | z.object({
147 | type: z.literal("persistResult"),
148 | requestingClientId: z.string(),
149 | leaderClientId: z.string(),
150 | persistId: z.string(),
151 | result: z.discriminatedUnion("type", [
152 | z.object({
153 | type: z.literal("success"),
154 | }),
155 | z.object({
156 | type: z.literal("failure"),
157 | error: z.string(),
158 | }),
159 | ]),
160 | }),
161 | ]);
162 | export type LeaderMessage = z.infer;
163 |
--------------------------------------------------------------------------------
/curvilinear/src/assets/icons/slack.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------