diff --git a/examples/linearlite/.gitignore b/examples/linearlite/.gitignore new file mode 100644 index 0000000000..c05ac13594 --- /dev/null +++ b/examples/linearlite/.gitignore @@ -0,0 +1,3 @@ +dist +.env.local +db/data/ \ No newline at end of file diff --git a/examples/linearlite/.prettierrc b/examples/linearlite/.prettierrc new file mode 100644 index 0000000000..f685078fff --- /dev/null +++ b/examples/linearlite/.prettierrc @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "semi": false, + "tabWidth": 2, + "singleQuote": true +} diff --git a/examples/linearlite/README.md b/examples/linearlite/README.md new file mode 100644 index 0000000000..1bc029139c --- /dev/null +++ b/examples/linearlite/README.md @@ -0,0 +1,76 @@ +# Linearlite + PGlite + ElectricSQL + +This is a demo app that shows how to build a local-first app using PGlite and the ElectricSQL sync engine. + +It's an example of a team collaboration app such as Linear built using ElectricSQL - a sync engine that synchronises little subsets of your Postgres data into local apps and services. So you can have the data you need, in-sync, wherever you need it. + +It's built on top of the excellent clone of the Linear UI built by [Tuan Nguyen](https://github.com/tuan3w). + +## Setup + +This example is part of the [ElectricSQL monorepo](../..) and is designed to be built and run as part of the [pnpm workspace](https://pnpm.io/workspaces) defined in [`../../pnpm-workspace.yaml`](../../pnpm-workspace.yaml). + +Navigate to the root directory of the monorepo, e.g.: + +```shell +cd ../../ +``` + +Install and build all of the workspace packages and examples: + +```shell +pnpm install +pnpm run -r build +``` + +Navigate back to this directory: + +```shell +cd examples/linearlite +``` + +Start the example backend services using [Docker Compose](https://docs.docker.com/compose/): + +```shell +pnpm backend:up +``` + +> Note that this always stops and deletes the volumes mounted by any other example backend containers that are running or have been run before. This ensures that the example always starts with a clean database and clean disk. + +Start the write path server: + +```shell +pnpm run write-server +``` + +Now start the dev server: + +```shell +pnpm dev +``` + +When you're done, stop the backend services using: + +```shell +pnpm backend:down +``` + +## How it works + +Linearlite demonstrates a local-first architecture using ElectricSQL and PGlite. Here's how the different pieces fit together: + +### Backend Components + +1. **Postgres Database**: The source of truth, containing the complete dataset. + +2. **Electric Sync Service**: Runs in front of Postgres, managing data synchronization from it to the clients. Preduces replication streams for a subset of the database called "shapes". + +3. **Write Server**: A simple HTTP server that handles write operations, applying them to the Postgres database. + +### Frontend Components + +1. **PGlite**: An in-browser database that stores a local copy of the data, enabling offline functionality and fast queries. + +2. **PGlite + Electric Sync Plugin**: Connects PGlite to the Electric sync service and loads the data into the local database. + +3. **React Frontend**: A Linear-inspired UI that interacts directly with the local database. diff --git a/examples/linearlite/backend/docker-compose.yml b/examples/linearlite/backend/docker-compose.yml new file mode 100644 index 0000000000..15fd2c886b --- /dev/null +++ b/examples/linearlite/backend/docker-compose.yml @@ -0,0 +1,30 @@ +version: "3.3" +name: "pglite-linearlite" + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: linearlite + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - 54321:5432 + volumes: + - ./postgres.conf:/etc/postgresql/postgresql.conf:ro + tmpfs: + - /var/lib/postgresql/data + - /tmp + command: + - postgres + - -c + - config_file=/etc/postgresql/postgresql.conf + + backend: + image: electricsql/electric + environment: + DATABASE_URL: postgresql://postgres:password@postgres:5432/linearlite?sslmode=disable + ports: + - 3000:3000 + depends_on: + - postgres diff --git a/examples/linearlite/backend/postgres.conf b/examples/linearlite/backend/postgres.conf new file mode 100644 index 0000000000..f28083ca8e --- /dev/null +++ b/examples/linearlite/backend/postgres.conf @@ -0,0 +1,2 @@ +listen_addresses = '*' +wal_level = logical \ No newline at end of file diff --git a/examples/linearlite/db/generate_data.js b/examples/linearlite/db/generate_data.js new file mode 100644 index 0000000000..a2e5240c92 --- /dev/null +++ b/examples/linearlite/db/generate_data.js @@ -0,0 +1,53 @@ +import { faker } from '@faker-js/faker' +import { generateNKeysBetween } from 'fractional-indexing' +import { v4 as uuidv4 } from 'uuid' + +export function generateIssues(numIssues) { + // generate properly spaced kanban keys and shuffle them + const kanbanKeys = faker.helpers.shuffle( + generateNKeysBetween(null, null, numIssues) + ) + return Array.from({ length: numIssues }, (_, idx) => + generateIssue(kanbanKeys[idx]) + ) +} + +function generateIssue(kanbanKey) { + const issueId = uuidv4() + const createdAt = faker.date.past() + return { + id: issueId, + title: faker.lorem.sentence({ min: 3, max: 8 }), + description: faker.lorem.sentences({ min: 2, max: 6 }, `\n`), + priority: faker.helpers.arrayElement([`none`, `low`, `medium`, `high`]), + status: faker.helpers.arrayElement([ + `backlog`, + `todo`, + `in_progress`, + `done`, + `canceled`, + ]), + created: createdAt.toISOString(), + modified: faker.date + .between({ from: createdAt, to: new Date() }) + .toISOString(), + kanbanorder: kanbanKey, + username: faker.internet.userName(), + comments: faker.helpers.multiple( + () => generateComment(issueId, createdAt), + { count: faker.number.int({ min: 0, max: 1 }) } + ), + } +} + +function generateComment(issueId, issueCreatedAt) { + const createdAt = faker.date.between({ from: issueCreatedAt, to: new Date() }) + return { + id: uuidv4(), + body: faker.lorem.text(), + username: faker.internet.userName(), + issue_id: issueId, + created: createdAt.toISOString(), + modified: createdAt.toISOString(), // comments are never modified + } +} diff --git a/examples/linearlite/db/load_data.js b/examples/linearlite/db/load_data.js new file mode 100644 index 0000000000..4a4f494e62 --- /dev/null +++ b/examples/linearlite/db/load_data.js @@ -0,0 +1,72 @@ +import postgres from 'postgres' +import { generateIssues } from './generate_data.js' + +if (!process.env.DATABASE_URL) { + throw new Error(`DATABASE_URL is not set`) +} + +const DATABASE_URL = process.env.DATABASE_URL +const ISSUES_TO_LOAD = process.env.ISSUES_TO_LOAD || 512 +const BATCH_SIZE = 1000 +const issues = generateIssues(ISSUES_TO_LOAD) + +console.info(`Connecting to Postgres at ${DATABASE_URL}`) +const sql = postgres(DATABASE_URL) + +async function batchInsert(sql, table, columns, dataArray, batchSize = 1000) { + for (let i = 0; i < dataArray.length; i += batchSize) { + const batch = dataArray.slice(i, i + batchSize) + + await sql` + INSERT INTO ${sql(table)} ${sql(batch, columns)} + ` + + process.stdout.write( + `Loaded ${Math.min(i + batchSize, dataArray.length)} of ${dataArray.length} ${table}s\r` + ) + } +} + +const issueCount = issues.length +let commentCount = 0 + +try { + // Process data in batches + for (let i = 0; i < issues.length; i += BATCH_SIZE) { + const issueBatch = issues.slice(i, i + BATCH_SIZE) + + await sql.begin(async (sql) => { + // Disable FK checks + await sql`SET CONSTRAINTS ALL DEFERRED` + + // Insert issues + const issuesData = issueBatch.map(({ comments: _, ...rest }) => rest) + const issueColumns = Object.keys(issuesData[0]) + await batchInsert(sql, 'issue', issueColumns, issuesData, BATCH_SIZE) + + // Insert related comments + const batchComments = issueBatch.flatMap((issue) => issue.comments) + const commentColumns = Object.keys(batchComments[0]) + await batchInsert( + sql, + 'comment', + commentColumns, + batchComments, + BATCH_SIZE + ) + + commentCount += batchComments.length + }) + + process.stdout.write( + `\nProcessed batch ${Math.floor(i / BATCH_SIZE) + 1}: ${Math.min(i + BATCH_SIZE, issues.length)} of ${issues.length} issues\n` + ) + } + + console.info(`Loaded ${issueCount} issues with ${commentCount} comments.`) +} catch (error) { + console.error('Error loading data:', error) + throw error +} finally { + await sql.end() +} diff --git a/examples/linearlite/db/migrations-client/01-create_tables.sql b/examples/linearlite/db/migrations-client/01-create_tables.sql new file mode 100644 index 0000000000..c0a5c1e4b9 --- /dev/null +++ b/examples/linearlite/db/migrations-client/01-create_tables.sql @@ -0,0 +1,290 @@ +-- # Tables and indexes +CREATE TABLE IF NOT EXISTS "issue" ( + "id" UUID NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "priority" TEXT NOT NULL, + "status" TEXT NOT NULL, + "modified" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "created" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "kanbanorder" TEXT NOT NULL, + "username" TEXT NOT NULL, + "deleted" BOOLEAN NOT NULL DEFAULT FALSE, -- Soft delete for local deletions + "new" BOOLEAN NOT NULL DEFAULT FALSE, -- New row flag for local inserts + "modified_columns" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], -- Columns that have been modified locally + "sent_to_server" BOOLEAN NOT NULL DEFAULT FALSE, -- Flag to track if the row has been sent to the server + "synced" BOOLEAN GENERATED ALWAYS AS (ARRAY_LENGTH(modified_columns, 1) IS NULL AND NOT deleted AND NOT new) STORED, + "backup" JSONB, -- JSONB column to store the backup of the row data for modified columns + CONSTRAINT "issue_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "comment" ( + "id" UUID NOT NULL, + "body" TEXT NOT NULL, + "username" TEXT NOT NULL, + "issue_id" UUID NOT NULL, + "modified" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "created" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "deleted" BOOLEAN NOT NULL DEFAULT FALSE, -- Soft delete for local deletions + "new" BOOLEAN NOT NULL DEFAULT FALSE, -- New row flag for local inserts + "modified_columns" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], -- Columns that have been modified locally + "sent_to_server" BOOLEAN NOT NULL DEFAULT FALSE, -- Flag to track if the row has been sent to the server + "synced" BOOLEAN GENERATED ALWAYS AS (ARRAY_LENGTH(modified_columns, 1) IS NULL AND NOT deleted AND NOT new) STORED, + "backup" JSONB, -- JSONB column to store the backup of the row data for modified columns + CONSTRAINT "comment_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "issue_id_idx" ON "issue" ("id"); + +CREATE INDEX IF NOT EXISTS "comment_id_idx" ON "comment" ("id"); + +-- During sync the electric.syncing config var is set to true +-- We can use this in triggers to determine the action that should be performed + +-- # Delete triggers: +-- - During sync we delete rows +-- - Otherwise we set the deleted flag to true +CREATE OR REPLACE FUNCTION handle_delete() +RETURNS TRIGGER AS $$ +DECLARE + is_syncing BOOLEAN; + bypass_triggers BOOLEAN; +BEGIN + -- Check if electric.syncing is true - defaults to false if not set + SELECT COALESCE(NULLIF(current_setting('electric.syncing', true), ''), 'false')::boolean INTO is_syncing; + -- Check if electric.bypass_triggers is true - defaults to false if not set + SELECT COALESCE(NULLIF(current_setting('electric.bypass_triggers', true), ''), 'false')::boolean INTO bypass_triggers; + + IF bypass_triggers THEN + RETURN OLD; + END IF; + + IF is_syncing THEN + -- If syncing we delete the row + RETURN OLD; + ELSE + -- For local deletions, check if the row is new + IF OLD.new THEN + -- If the row is new, just delete it + RETURN OLD; + ELSE + -- Otherwise, set the deleted flag instead of actually deleting + EXECUTE format('UPDATE %I SET deleted = true WHERE id = $1', TG_TABLE_NAME) USING OLD.id; + RETURN NULL; + END IF; + END IF; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE TRIGGER issue_delete_trigger +BEFORE DELETE ON issue +FOR EACH ROW +EXECUTE FUNCTION handle_delete(); + +CREATE OR REPLACE TRIGGER comment_delete_trigger +BEFORE DELETE ON comment +FOR EACH ROW +EXECUTE FUNCTION handle_delete(); + +-- # Insert triggers: +-- - During sync we insert rows and set modified_columns = [] +-- - Otherwise we insert rows and set modified_columns to contain the names of all +-- columns that are not local-state related + +CREATE OR REPLACE FUNCTION handle_insert() +RETURNS TRIGGER AS $$ +DECLARE + is_syncing BOOLEAN; + bypass_triggers BOOLEAN; + modified_columns TEXT[] := ARRAY[]::TEXT[]; + col_name TEXT; + new_value TEXT; + old_value TEXT; +BEGIN + -- Check if electric.syncing is true - defaults to false if not set + SELECT COALESCE(NULLIF(current_setting('electric.syncing', true), ''), 'false')::boolean INTO is_syncing; + -- Check if electric.bypass_triggers is true - defaults to false if not set + SELECT COALESCE(NULLIF(current_setting('electric.bypass_triggers', true), ''), 'false')::boolean INTO bypass_triggers; + + IF bypass_triggers THEN + RETURN NEW; + END IF; + + IF is_syncing THEN + -- If syncing, we set modified_columns to an empty array + NEW.modified_columns := ARRAY[]::TEXT[]; + NEW.new := FALSE; + NEW.sent_to_server := FALSE; + -- If the row already exists in the database, handle it as an update + EXECUTE format('SELECT 1 FROM %I WHERE id = $1', TG_TABLE_NAME) USING NEW.id INTO old_value; + IF old_value IS NOT NULL THEN + -- Apply update logic similar to handle_update function + FOR col_name IN SELECT column_name + FROM information_schema.columns + WHERE table_name = TG_TABLE_NAME AND + table_schema = TG_TABLE_SCHEMA AND + column_name NOT IN ('id', 'synced', 'modified_columns', 'backup', 'deleted', 'new', 'sent_to_server', 'search_vector') LOOP + EXECUTE format('SELECT $1.%I', col_name) USING NEW INTO new_value; + EXECUTE format('SELECT %I FROM %I WHERE id = $1', col_name, TG_TABLE_NAME) USING NEW.id INTO old_value; + IF new_value IS DISTINCT FROM old_value THEN + EXECUTE format('UPDATE %I SET %I = $1 WHERE id = $2', TG_TABLE_NAME, col_name) USING new_value, NEW.id; + END IF; + END LOOP; + -- Update modified_columns + EXECUTE format('UPDATE %I SET modified_columns = $1 WHERE id = $2', TG_TABLE_NAME) + USING ARRAY[]::TEXT[], NEW.id; + -- Update new flag + EXECUTE format('UPDATE %I SET new = $1 WHERE id = $2', TG_TABLE_NAME) + USING FALSE, NEW.id; + -- Update sent_to_server flag + EXECUTE format('UPDATE %I SET sent_to_server = $1 WHERE id = $2', TG_TABLE_NAME) + USING FALSE, NEW.id; + RETURN NULL; -- Prevent insertion of a new row + END IF; + ELSE + -- For local inserts, we add all non-local-state columns to modified_columns + SELECT array_agg(column_name) INTO modified_columns + FROM information_schema.columns + WHERE table_name = TG_TABLE_NAME + AND column_name NOT IN ('id', 'synced', 'modified_columns', 'backup', 'deleted', 'new', 'sent_to_server', 'search_vector'); + NEW.modified_columns := modified_columns; + NEW.new := TRUE; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE TRIGGER issue_insert_trigger +BEFORE INSERT ON issue +FOR EACH ROW +EXECUTE FUNCTION handle_insert(); + +CREATE OR REPLACE TRIGGER comment_insert_trigger +BEFORE INSERT ON comment +FOR EACH ROW +EXECUTE FUNCTION handle_insert(); + +-- # Update triggers: +-- - During sync: +-- - If the new modified timestamp is >= the one in the database, we apply the update, +-- set modified_columns = [], and set backup = NULL +-- - Otherwise we apply the update to columns that are NOT in modified_columns and +-- - and save the values for the non-updated columns in the backup JSONB column +-- - During a non-sync transaction: +-- - If we write over a column (that are not local-state related) that was not +-- already modified, we add that column name to modified_columns, and copy the +-- current value from the column to the backup JSONB column +-- - Otherwise we just update the column + +CREATE OR REPLACE FUNCTION handle_update() +RETURNS TRIGGER AS $$ +DECLARE + is_syncing BOOLEAN; + bypass_triggers BOOLEAN; + column_name TEXT; + old_value TEXT; + new_value TEXT; +BEGIN + -- Check if electric.syncing is true - defaults to false if not set + SELECT COALESCE(NULLIF(current_setting('electric.syncing', true), ''), 'false')::boolean INTO is_syncing; + -- Check if electric.bypass_triggers is true - defaults to false if not set + SELECT COALESCE(NULLIF(current_setting('electric.bypass_triggers', true), ''), 'false')::boolean INTO bypass_triggers; + + IF bypass_triggers THEN + RETURN NEW; + END IF; + + IF is_syncing THEN + -- During sync + IF (OLD.synced = TRUE) OR (OLD.sent_to_server = TRUE AND NEW.modified >= OLD.modified) THEN + -- Apply the update, reset modified_columns, backup, new, and sent_to_server flags + NEW.modified_columns := ARRAY[]::TEXT[]; + NEW.backup := NULL; + NEW.new := FALSE; + NEW.sent_to_server := FALSE; + ELSE + -- Apply update only to columns not in modified_columns + FOR column_name IN SELECT columns.column_name + FROM information_schema.columns + WHERE columns.table_name = TG_TABLE_NAME + AND columns.table_schema = TG_TABLE_SCHEMA + AND columns.column_name NOT IN ('id', 'synced', 'modified_columns', 'backup', 'deleted', 'new', 'sent_to_server', 'search_vector') LOOP + IF column_name != ANY(OLD.modified_columns) THEN + EXECUTE format('SELECT ($1).%I', column_name) USING NEW INTO new_value; + EXECUTE format('SELECT ($1).%I', column_name) USING OLD INTO old_value; + IF new_value IS DISTINCT FROM old_value THEN + EXECUTE format('UPDATE %I SET %I = $1 WHERE id = $2', TG_TABLE_NAME, column_name) USING new_value, NEW.id; + NEW.backup := jsonb_set(COALESCE(NEW.backup, '{}'::jsonb), ARRAY[column_name], to_jsonb(old_value)); + END IF; + END IF; + END LOOP; + NEW.new := FALSE; + END IF; + ELSE + -- During non-sync transaction + FOR column_name IN SELECT columns.column_name + FROM information_schema.columns + WHERE columns.table_name = TG_TABLE_NAME + AND columns.table_schema = TG_TABLE_SCHEMA + AND columns.column_name NOT IN ('id', 'synced', 'modified_columns', 'backup', 'deleted', 'new', 'sent_to_server', 'search_vector') LOOP + EXECUTE format('SELECT ($1).%I', column_name) USING NEW INTO new_value; + EXECUTE format('SELECT ($1).%I', column_name) USING OLD INTO old_value; + IF new_value IS DISTINCT FROM old_value THEN + IF NOT (column_name = ANY(OLD.modified_columns)) THEN + NEW.modified_columns := array_append(NEW.modified_columns, column_name); + NEW.backup := jsonb_set(COALESCE(NEW.backup, '{}'::jsonb), ARRAY[column_name], to_jsonb(old_value)); + END IF; + END IF; + END LOOP; + NEW.sent_to_server := FALSE; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE TRIGGER issue_update_trigger +BEFORE UPDATE ON issue +FOR EACH ROW +EXECUTE FUNCTION handle_update(); + +CREATE OR REPLACE TRIGGER comment_update_trigger +BEFORE UPDATE ON comment +FOR EACH ROW +EXECUTE FUNCTION handle_update(); + +-- # Functions to revert local changes using the backup column + +CREATE OR REPLACE FUNCTION revert_local_changes(table_name TEXT, row_id UUID) +RETURNS VOID AS $$ +DECLARE + backup_data JSONB; + column_name TEXT; + column_value JSONB; +BEGIN + EXECUTE format('SELECT backup FROM %I WHERE id = $1', table_name) + INTO backup_data + USING row_id; + + IF backup_data IS NOT NULL THEN + FOR column_name, column_value IN SELECT * FROM jsonb_each(backup_data) + LOOP + EXECUTE format('UPDATE %I SET %I = $1, modified_columns = array_remove(modified_columns, $2) WHERE id = $3', table_name, column_name) + USING column_value, column_name, row_id; + END LOOP; + + -- Clear the backup after reverting + EXECUTE format('UPDATE %I SET backup = NULL WHERE id = $1', table_name) + USING row_id; + END IF; +END; +$$ LANGUAGE plpgsql; + +-- Example usage: +-- SELECT revert_local_changes('issue', '123e4567-e89b-12d3-a456-426614174000'); +-- SELECT revert_local_changes('comment', '123e4567-e89b-12d3-a456-426614174001'); + + +ALTER TABLE issue DISABLE TRIGGER ALL; +ALTER TABLE comment DISABLE TRIGGER ALL; diff --git a/examples/linearlite/db/migrations-client/post-initial-sync-fts-index.sql b/examples/linearlite/db/migrations-client/post-initial-sync-fts-index.sql new file mode 100644 index 0000000000..9e292c053d --- /dev/null +++ b/examples/linearlite/db/migrations-client/post-initial-sync-fts-index.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS "issue_search_idx" ON "issue" USING GIN ((setweight(to_tsvector('simple', coalesce(title, '')), 'A') || setweight(to_tsvector('simple', coalesce(description, '')), 'B'))); \ No newline at end of file diff --git a/examples/linearlite/db/migrations-client/post-initial-sync-indexes.sql b/examples/linearlite/db/migrations-client/post-initial-sync-indexes.sql new file mode 100644 index 0000000000..30328e4677 --- /dev/null +++ b/examples/linearlite/db/migrations-client/post-initial-sync-indexes.sql @@ -0,0 +1,11 @@ +CREATE INDEX IF NOT EXISTS "issue_priority_idx" ON "issue" ("priority"); +CREATE INDEX IF NOT EXISTS "issue_status_idx" ON "issue" ("status"); +CREATE INDEX IF NOT EXISTS "issue_modified_idx" ON "issue" ("modified"); +CREATE INDEX IF NOT EXISTS "issue_created_idx" ON "issue" ("created"); +CREATE INDEX IF NOT EXISTS "issue_kanbanorder_idx" ON "issue" ("kanbanorder"); +CREATE INDEX IF NOT EXISTS "issue_deleted_idx" ON "issue" ("deleted"); +CREATE INDEX IF NOT EXISTS "issue_synced_idx" ON "issue" ("synced"); +CREATE INDEX IF NOT EXISTS "comment_issue_id_idxx" ON "comment" ("issue_id"); +CREATE INDEX IF NOT EXISTS "comment_created_idx" ON "comment" ("created"); +CREATE INDEX IF NOT EXISTS "comment_deleted_idx" ON "comment" ("deleted"); +CREATE INDEX IF NOT EXISTS "comment_synced_idx" ON "comment" ("synced"); \ No newline at end of file diff --git a/examples/linearlite/db/migrations-server/01-create_tables.sql b/examples/linearlite/db/migrations-server/01-create_tables.sql new file mode 100644 index 0000000000..84c2cac11e --- /dev/null +++ b/examples/linearlite/db/migrations-server/01-create_tables.sql @@ -0,0 +1,24 @@ +-- Create the tables for the linearlite example +CREATE TABLE IF NOT EXISTS "issue" ( + "id" UUID NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "priority" TEXT NOT NULL, + "status" TEXT NOT NULL, + "modified" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "created" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "kanbanorder" TEXT NOT NULL, + "username" TEXT NOT NULL, + CONSTRAINT "issue_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "comment" ( + "id" UUID NOT NULL, + "body" TEXT NOT NULL, + "username" TEXT NOT NULL, + "issue_id" UUID NOT NULL, + "modified" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "created" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT "comment_pkey" PRIMARY KEY ("id"), + FOREIGN KEY (issue_id) REFERENCES issue(id) ON DELETE CASCADE +); diff --git a/examples/linearlite/eslint.config.js b/examples/linearlite/eslint.config.js new file mode 100644 index 0000000000..99c462472e --- /dev/null +++ b/examples/linearlite/eslint.config.js @@ -0,0 +1,87 @@ +// @ts-expect-error no types +import js from '@eslint/js' +// @ts-expect-error no types +import { FlatCompat } from '@eslint/eslintrc' +import globals from 'globals' + +import eslintTsParser from '@typescript-eslint/parser' +import tsPlugin from '@typescript-eslint/eslint-plugin' + +import pluginReact from "@eslint-react/eslint-plugin"; +// @ts-expect-error no types +import pluginReactCompiler from "eslint-plugin-react-compiler"; +// @ts-expect-error no types +import pluginReactHooks from "eslint-plugin-react-hooks"; + +const compat = new FlatCompat() + +export default [ + js.configs.recommended, + ...compat.extends('plugin:@typescript-eslint/recommended'), + { + languageOptions: { + parser: eslintTsParser, + globals: { + ...globals.browser, + }, + }, + files: ['**/*.{ts,tsx}'], + plugins: { + '@typescript-eslint': tsPlugin, + }, + rules: { + ...tsPlugin.configs.recommended?.rules, + '@typescript-eslint/no-unused-vars': [ + 'warn', // or "error" + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/no-inferrable-types': 'off', // always allow explicit typings + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-unused-expressions': 'off', + '@typescript-eslint/no-explicit-any': ['warn', { ignoreRestArgs: true }], + '@typescript-eslint/ban-ts-comment': [ + 'error', + { 'ts-ignore': 'allow-with-description' }, + ], + 'no-constant-condition': ['error', { checkLoops: false }], + eqeqeq: ['error'], + }, + }, + { + files: ['**/*.cjs'], + languageOptions: { + globals: { + ...globals.node, + }, + }, + rules: { + '@typescript-eslint/no-var-requires': 'off', + }, + }, + { + files: ["**/*.{ts,tsx}"], + ...pluginReact.configs.recommended, + }, + { + plugins: { + "react-hooks": pluginReactHooks, + "react-compiler": pluginReactCompiler, + }, + rules: { + "react-compiler/react-compiler": "error", + "react-hooks/exhaustive-deps": "error", + "react-hooks/rules-of-hooks": "error", + }, + }, + { + files: ["**/test/**"], + rules: { + "@typescript-eslint/no-unnecessary-condition": "off", + "react-compiler/react-compiler": "off", + }, + }, +] diff --git a/examples/linearlite/index.html b/examples/linearlite/index.html new file mode 100644 index 0000000000..124f0f97f5 --- /dev/null +++ b/examples/linearlite/index.html @@ -0,0 +1,21 @@ + + + + + + + LinearLite + + + + + + +
+
+ + + diff --git a/examples/linearlite/package.json b/examples/linearlite/package.json new file mode 100644 index 0000000000..8d49b8cec9 --- /dev/null +++ b/examples/linearlite/package.json @@ -0,0 +1,104 @@ +{ + "name": "@electric-examples/linearlite", + "version": "0.0.1", + "license": "Apache-2.0", + "private": true, + "type": "module", + "scripts": { + "backend:down": "PROJECT_NAME=linearlite pnpm -C ../../ run example-backend:down", + "backend:up": "PROJECT_NAME=linearlite pnpm -C ../../ run example-backend:up && pnpm db:migrate && pnpm db:load-data", + "db:migrate": "dotenv -e ../../.env.dev -- pnpm exec pg-migrations apply --directory ./db/migrations-server", + "db:load-data": "dotenv -e ../../.env.dev -- node ./db/load_data.js", + "reset": "pnpm backend:down && pnpm backend:up && pnpm db:migrate && pnpm db:load-data", + "write-server": "dotenv -e ../../.env.dev -- tsx server.ts", + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "process-data": "node ./db/process_data.js", + "format": "prettier --write ./src", + "lint": "eslint ./src", + "stylecheck": "eslint ./src && prettier --check ./src ./db" + }, + "dependencies": { + "@electric-sql/pglite": "^0.2.15", + "@electric-sql/pglite-react": "^0.2.15", + "@electric-sql/pglite-repl": "^0.2.15", + "@electric-sql/pglite-sync": "^0.2.17", + "@firefox-devtools/react-contextmenu": "^5.1.1", + "@headlessui/react": "^1.7.17", + "@hono/node-server": "^1.8.0", + "@svgr/plugin-jsx": "^8.1.0", + "@svgr/plugin-svgo": "^8.1.0", + "@tailwindcss/forms": "^0.5.6", + "@tiptap/extension-placeholder": "^2.4.0", + "@tiptap/extension-table": "^2.4.0", + "@tiptap/extension-table-cell": "^2.4.0", + "@tiptap/extension-table-header": "^2.4.0", + "@tiptap/extension-table-row": "^2.4.0", + "@tiptap/pm": "^2.4.0", + "@tiptap/react": "^2.4.0", + "@tiptap/starter-kit": "^2.4.0", + "animate.css": "^4.1.1", + "body-parser": "^1.20.3", + "classnames": "^2.5.1", + "cors": "^2.8.5", + "dayjs": "^1.11.11", + "dotenv": "^16.4.5", + "fractional-indexing": "^3.2.0", + "hono": "^4.0.0", + "jsonwebtoken": "^9.0.2", + "lodash.debounce": "^4.0.8", + "postgres": "^3.4.3", + "react": "^18.3.1", + "react-beautiful-dnd": "^13.1.1", + "react-dom": "^18.3.1", + "react-icons": "^4.10.1", + "react-markdown": "^8.0.7", + "react-router-dom": "^6.24.1", + "react-toastify": "^9.1.3", + "react-virtualized-auto-sizer": "^1.0.24", + "react-window": "^1.8.10", + "tiptap-markdown": "^0.8.2", + "uuid": "^9.0.0", + "vite-plugin-svgr": "^3.2.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@databases/pg-migrations": "^5.0.3", + "@eslint-react/eslint-plugin": "^1.18.0", + "@faker-js/faker": "^8.4.1", + "@tailwindcss/typography": "^0.5.10", + "@types/body-parser": "^1.19.5", + "@types/jest": "^29.5.12", + "@types/lodash.debounce": "^4.0.9", + "@types/node": "^20.14.10", + "@types/pg": "^8.11.10", + "@types/react": "^18.3.3", + "@types/react-beautiful-dnd": "^13.1.8", + "@types/react-dom": "^18.3.0", + "@types/react-router-dom": "^5.3.3", + "@types/react-window": "^1.8.8", + "@types/uuid": "^9.0.3", + "@types/vite-plugin-react-svg": "^0.2.5", + "@typescript-eslint/eslint-plugin": "^8.17.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "dotenv-cli": "^7.4.2", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", + "eslint-plugin-react-compiler": "19.0.0-beta-37ed2a7-20241206", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.3", + "fs-extra": "^10.0.0", + "globals": "^15.13.0", + "postcss": "^8.4.39", + "sst": "3.3.7", + "supabase": "^1.226.3", + "tailwindcss": "^3.4.4", + "tsx": "^4.19.1", + "typescript": "^5.5.3", + "vite": "^5.4.8" + } +} diff --git a/examples/linearlite/postcss.config.mjs b/examples/linearlite/postcss.config.mjs new file mode 100644 index 0000000000..2e7af2b7f1 --- /dev/null +++ b/examples/linearlite/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/examples/linearlite/public/electric-icon.png b/examples/linearlite/public/electric-icon.png new file mode 100644 index 0000000000..4ed9e1e142 Binary files /dev/null and b/examples/linearlite/public/electric-icon.png differ diff --git a/examples/linearlite/public/favicon.ico b/examples/linearlite/public/favicon.ico new file mode 100644 index 0000000000..d1db33bc73 Binary files /dev/null and b/examples/linearlite/public/favicon.ico differ diff --git a/examples/linearlite/public/logo192.png b/examples/linearlite/public/logo192.png new file mode 100644 index 0000000000..ec14f24522 Binary files /dev/null and b/examples/linearlite/public/logo192.png differ diff --git a/examples/linearlite/public/logo512.png b/examples/linearlite/public/logo512.png new file mode 100644 index 0000000000..ec14f24522 Binary files /dev/null and b/examples/linearlite/public/logo512.png differ diff --git a/examples/linearlite/public/netlify.toml b/examples/linearlite/public/netlify.toml new file mode 100644 index 0000000000..ec5ebb8643 --- /dev/null +++ b/examples/linearlite/public/netlify.toml @@ -0,0 +1,5 @@ + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 diff --git a/examples/linearlite/public/robots.txt b/examples/linearlite/public/robots.txt new file mode 100644 index 0000000000..e9e57dc4d4 --- /dev/null +++ b/examples/linearlite/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/examples/linearlite/server.ts b/examples/linearlite/server.ts new file mode 100644 index 0000000000..5fee4e2c57 --- /dev/null +++ b/examples/linearlite/server.ts @@ -0,0 +1,101 @@ +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import postgres from 'postgres' +import { + ChangeSet, + changeSetSchema, + CommentChange, + IssueChange, +} from './src/utils/changes' +import { serve } from '@hono/node-server' + +const DATABASE_URL = + process.env.DATABASE_URL ?? + 'postgresql://postgres:password@localhost:54321/linearlite' + +// Create postgres connection +const sql = postgres(DATABASE_URL) + +const app = new Hono() + +// Middleware +app.use('/*', cors()) + +// Routes +app.get('/', async (c) => { + const result = await sql` + SELECT 'ok' as status, version() as postgres_version, now() as server_time + ` + return c.json(result[0]) +}) + +app.post('/apply-changes', async (c) => { + const content = await c.req.json() + let parsedChanges: ChangeSet + try { + parsedChanges = changeSetSchema.parse(content) + // Any additional validation of the changes can be done here! + } catch (error) { + console.error(error) + return c.json({ error: 'Invalid changes' }, 400) + } + const changeResponse = await applyChanges(parsedChanges) + return c.json(changeResponse) +}) + +// Start the server +const port = 3001 +console.log(`Server is running on port ${port}`) + +serve({ + fetch: app.fetch, + port, +}) + +async function applyChanges(changes: ChangeSet): Promise<{ success: boolean }> { + const { issues, comments } = changes + + try { + await sql.begin(async (sql) => { + for (const issue of issues) { + await applyTableChange('issue', issue, sql) + } + for (const comment of comments) { + await applyTableChange('comment', comment, sql) + } + }) + return { success: true } + } catch (error) { + throw error + } +} + +async function applyTableChange( + tableName: 'issue' | 'comment', + change: IssueChange | CommentChange, + sql: postgres.TransactionSql +): Promise { + const { + id, + modified_columns: modified_columns_raw, + new: isNew, + deleted, + } = change + const modified_columns = modified_columns_raw as (keyof typeof change)[] + + if (deleted) { + await sql` + DELETE FROM ${sql(tableName)} WHERE id = ${id} + ` + } else if (isNew) { + await sql` + INSERT INTO ${sql(tableName)} ${sql(change, 'id', ...modified_columns)} + ` + } else { + await sql` + UPDATE ${sql(tableName)} + SET ${sql(change, ...modified_columns)} + WHERE id = ${id} + ` + } +} diff --git a/examples/linearlite/src/App.tsx b/examples/linearlite/src/App.tsx new file mode 100644 index 0000000000..8a1fde3036 --- /dev/null +++ b/examples/linearlite/src/App.tsx @@ -0,0 +1,203 @@ +import 'animate.css/animate.min.css' +import Board from './pages/Board' +import { useState, createContext, useEffect, useMemo } from 'react' +import { + createBrowserRouter, + RouterProvider, + type Params, +} from 'react-router-dom' +import 'react-toastify/dist/ReactToastify.css' +import { live, LiveNamespace, LiveQuery } from '@electric-sql/pglite/live' +import { PGliteWorker } from '@electric-sql/pglite/worker' +import { PGliteProvider } from '@electric-sql/pglite-react' +import PGWorker from './pglite-worker.js?worker' +import List from './pages/List' +import Root from './pages/root' +import Issue from './pages/Issue' +import { + getFilterStateFromSearchParams, + filterStateToSql, + FilterState, +} from './utils/filterState' +import { Issue as IssueType, Status, StatusValue } from './types/types' +import { startSync, useSyncStatus, waitForInitialSyncDone } from './sync' +import { electricSync } from '@electric-sql/pglite-sync' +import { ImSpinner8 } from 'react-icons/im' + +interface MenuContextInterface { + showMenu: boolean + setShowMenu: (show: boolean) => void +} + +export const MenuContext = createContext(null as MenuContextInterface | null) + +type PGliteWorkerWithLive = PGliteWorker & { live: LiveNamespace } + +async function createPGlite() { + return PGliteWorker.create(new PGWorker(), { + extensions: { + live, + sync: electricSync(), + }, + }) +} + +const pgPromise = createPGlite() + +let syncStarted = false +pgPromise.then(async (pg) => { + console.log('PGlite worker started') + pg.onLeaderChange(() => { + console.log('Leader changed, isLeader:', pg.isLeader) + if (pg.isLeader && !syncStarted) { + syncStarted = true + startSync(pg) + } + }) +}) + +async function issueListLoader({ request }: { request: Request }) { + await waitForInitialSyncDone() + const pg = await pgPromise + const url = new URL(request.url) + const filterState = getFilterStateFromSearchParams(url.searchParams) + const { sql, sqlParams } = filterStateToSql(filterState) + const liveIssues = await pg.live.query({ + query: sql, + params: sqlParams, + signal: request.signal, + offset: 0, + limit: 100, + }) + return { liveIssues, filterState } +} + +async function boardIssueListLoader({ request }: { request: Request }) { + await waitForInitialSyncDone() + const pg = await pgPromise + const url = new URL(request.url) + const filterState = getFilterStateFromSearchParams(url.searchParams) + + const columnsLiveIssues: Partial>> = + {} + + for (const status of Object.values(Status)) { + const colFilterState: FilterState = { + ...filterState, + orderBy: 'kanbanorder', + orderDirection: 'asc', + status: [status], + } + const { sql: colSql, sqlParams: colSqlParams } = + filterStateToSql(colFilterState) + const colLiveIssues = await pg.live.query({ + query: colSql, + params: colSqlParams, + signal: request.signal, + offset: 0, + limit: 10, + }) + columnsLiveIssues[status] = colLiveIssues + } + + return { + columnsLiveIssues: columnsLiveIssues as Record< + StatusValue, + LiveQuery + >, + filterState, + } +} + +async function issueLoader({ + params, + request, +}: { + params: Params + request: Request +}) { + const pg = await pgPromise + const liveIssue = await pg.live.query({ + query: `SELECT * FROM issue WHERE id = $1`, + params: [params.id], + signal: request.signal, + }) + return { liveIssue } +} + +const router = createBrowserRouter([ + { + path: `/`, + element: , + children: [ + { + path: `/`, + element: , + loader: issueListLoader, + }, + { + path: `/search`, + element: , + loader: issueListLoader, + }, + { + path: `/board`, + element: , + loader: boardIssueListLoader, + }, + { + path: `/issue/:id`, + element: , + loader: issueLoader, + }, + ], + }, +]) + +const LoadingScreen = ({ children }: { children: React.ReactNode }) => { + return ( +
+ +
+ {children} +
+
+ ) +} + +const App = () => { + const [showMenu, setShowMenu] = useState(false) + const [pgForProvider, setPgForProvider] = + useState(null) + const [syncStatus, syncMessage] = useSyncStatus() + + useEffect(() => { + pgPromise.then(setPgForProvider) + }, []) + + const menuContextValue = useMemo( + () => ({ showMenu, setShowMenu }), + [showMenu] + ) + + if (!pgForProvider) return Starting PGlite... + + if (syncStatus === 'initial-sync') + return ( + + Performing initial sync... +
+ {syncMessage} +
+ ) + + return ( + + + + + + ) +} + +export default App diff --git a/examples/linearlite/src/assets/fonts/27237475-28043385 b/examples/linearlite/src/assets/fonts/27237475-28043385 new file mode 100644 index 0000000000..21a8f50783 Binary files /dev/null and b/examples/linearlite/src/assets/fonts/27237475-28043385 differ diff --git a/examples/linearlite/src/assets/fonts/Inter-UI-ExtraBold.woff b/examples/linearlite/src/assets/fonts/Inter-UI-ExtraBold.woff new file mode 100644 index 0000000000..4f61ad0ec2 Binary files /dev/null and b/examples/linearlite/src/assets/fonts/Inter-UI-ExtraBold.woff differ diff --git a/examples/linearlite/src/assets/fonts/Inter-UI-ExtraBold.woff2 b/examples/linearlite/src/assets/fonts/Inter-UI-ExtraBold.woff2 new file mode 100644 index 0000000000..19b58e07c3 Binary files /dev/null and b/examples/linearlite/src/assets/fonts/Inter-UI-ExtraBold.woff2 differ diff --git a/examples/linearlite/src/assets/fonts/Inter-UI-Medium.woff b/examples/linearlite/src/assets/fonts/Inter-UI-Medium.woff new file mode 100644 index 0000000000..860da965f6 Binary files /dev/null and b/examples/linearlite/src/assets/fonts/Inter-UI-Medium.woff differ diff --git a/examples/linearlite/src/assets/fonts/Inter-UI-Medium.woff2 b/examples/linearlite/src/assets/fonts/Inter-UI-Medium.woff2 new file mode 100644 index 0000000000..c35427fa70 Binary files /dev/null and b/examples/linearlite/src/assets/fonts/Inter-UI-Medium.woff2 differ diff --git a/examples/linearlite/src/assets/fonts/Inter-UI-Regular.woff b/examples/linearlite/src/assets/fonts/Inter-UI-Regular.woff new file mode 100644 index 0000000000..dea6032204 Binary files /dev/null and b/examples/linearlite/src/assets/fonts/Inter-UI-Regular.woff differ diff --git a/examples/linearlite/src/assets/fonts/Inter-UI-Regular.woff2 b/examples/linearlite/src/assets/fonts/Inter-UI-Regular.woff2 new file mode 100644 index 0000000000..cddb436531 Binary files /dev/null and b/examples/linearlite/src/assets/fonts/Inter-UI-Regular.woff2 differ diff --git a/examples/linearlite/src/assets/fonts/Inter-UI-SemiBold.woff b/examples/linearlite/src/assets/fonts/Inter-UI-SemiBold.woff new file mode 100644 index 0000000000..ec6f74b0b9 Binary files /dev/null and b/examples/linearlite/src/assets/fonts/Inter-UI-SemiBold.woff differ diff --git a/examples/linearlite/src/assets/fonts/Inter-UI-SemiBold.woff2 b/examples/linearlite/src/assets/fonts/Inter-UI-SemiBold.woff2 new file mode 100644 index 0000000000..ec6f74b0b9 Binary files /dev/null and b/examples/linearlite/src/assets/fonts/Inter-UI-SemiBold.woff2 differ diff --git a/examples/linearlite/src/assets/icons/add-subissue.svg b/examples/linearlite/src/assets/icons/add-subissue.svg new file mode 100644 index 0000000000..e08d380d9f --- /dev/null +++ b/examples/linearlite/src/assets/icons/add-subissue.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/add.svg b/examples/linearlite/src/assets/icons/add.svg new file mode 100644 index 0000000000..77d1805c13 --- /dev/null +++ b/examples/linearlite/src/assets/icons/add.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/archive.svg b/examples/linearlite/src/assets/icons/archive.svg new file mode 100644 index 0000000000..791b3b817c --- /dev/null +++ b/examples/linearlite/src/assets/icons/archive.svg @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/assignee.svg b/examples/linearlite/src/assets/icons/assignee.svg new file mode 100644 index 0000000000..0110ea5b4c --- /dev/null +++ b/examples/linearlite/src/assets/icons/assignee.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/attachment.svg b/examples/linearlite/src/assets/icons/attachment.svg new file mode 100644 index 0000000000..408625211c --- /dev/null +++ b/examples/linearlite/src/assets/icons/attachment.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/avatar.svg b/examples/linearlite/src/assets/icons/avatar.svg new file mode 100644 index 0000000000..3021b14911 --- /dev/null +++ b/examples/linearlite/src/assets/icons/avatar.svg @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/cancel.svg b/examples/linearlite/src/assets/icons/cancel.svg new file mode 100644 index 0000000000..f5fe6e8731 --- /dev/null +++ b/examples/linearlite/src/assets/icons/cancel.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/chat.svg b/examples/linearlite/src/assets/icons/chat.svg new file mode 100644 index 0000000000..05882262e4 --- /dev/null +++ b/examples/linearlite/src/assets/icons/chat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/circle-dot.svg b/examples/linearlite/src/assets/icons/circle-dot.svg new file mode 100644 index 0000000000..67568703ba --- /dev/null +++ b/examples/linearlite/src/assets/icons/circle-dot.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/linearlite/src/assets/icons/circle.svg b/examples/linearlite/src/assets/icons/circle.svg new file mode 100644 index 0000000000..fac3b81fcc --- /dev/null +++ b/examples/linearlite/src/assets/icons/circle.svg @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/claim.svg b/examples/linearlite/src/assets/icons/claim.svg new file mode 100644 index 0000000000..6a31eb3769 --- /dev/null +++ b/examples/linearlite/src/assets/icons/claim.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/close.svg b/examples/linearlite/src/assets/icons/close.svg new file mode 100644 index 0000000000..f17488caa2 --- /dev/null +++ b/examples/linearlite/src/assets/icons/close.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/delete.svg b/examples/linearlite/src/assets/icons/delete.svg new file mode 100644 index 0000000000..eb3fe397ee --- /dev/null +++ b/examples/linearlite/src/assets/icons/delete.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/done.svg b/examples/linearlite/src/assets/icons/done.svg new file mode 100644 index 0000000000..4904f818d0 --- /dev/null +++ b/examples/linearlite/src/assets/icons/done.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/linearlite/src/assets/icons/dots.svg b/examples/linearlite/src/assets/icons/dots.svg new file mode 100644 index 0000000000..8193e5c160 --- /dev/null +++ b/examples/linearlite/src/assets/icons/dots.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/due-date.svg b/examples/linearlite/src/assets/icons/due-date.svg new file mode 100644 index 0000000000..bf888e0fdc --- /dev/null +++ b/examples/linearlite/src/assets/icons/due-date.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/dupplication.svg b/examples/linearlite/src/assets/icons/dupplication.svg new file mode 100644 index 0000000000..3d5ebb62a8 --- /dev/null +++ b/examples/linearlite/src/assets/icons/dupplication.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/filter.svg b/examples/linearlite/src/assets/icons/filter.svg new file mode 100644 index 0000000000..86ea60ed8c --- /dev/null +++ b/examples/linearlite/src/assets/icons/filter.svg @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/git-issue.svg b/examples/linearlite/src/assets/icons/git-issue.svg new file mode 100644 index 0000000000..de681ca9fc --- /dev/null +++ b/examples/linearlite/src/assets/icons/git-issue.svg @@ -0,0 +1,4 @@ + + + + diff --git a/examples/linearlite/src/assets/icons/guide.svg b/examples/linearlite/src/assets/icons/guide.svg new file mode 100644 index 0000000000..41f941a760 --- /dev/null +++ b/examples/linearlite/src/assets/icons/guide.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/half-circle.svg b/examples/linearlite/src/assets/icons/half-circle.svg new file mode 100644 index 0000000000..7a4f0b28d2 --- /dev/null +++ b/examples/linearlite/src/assets/icons/half-circle.svg @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/help.svg b/examples/linearlite/src/assets/icons/help.svg new file mode 100644 index 0000000000..88ed0a4d95 --- /dev/null +++ b/examples/linearlite/src/assets/icons/help.svg @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/inbox.svg b/examples/linearlite/src/assets/icons/inbox.svg new file mode 100644 index 0000000000..8bbd1f3751 --- /dev/null +++ b/examples/linearlite/src/assets/icons/inbox.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/issue.svg b/examples/linearlite/src/assets/icons/issue.svg new file mode 100644 index 0000000000..b7ac9f03d5 --- /dev/null +++ b/examples/linearlite/src/assets/icons/issue.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/label.svg b/examples/linearlite/src/assets/icons/label.svg new file mode 100644 index 0000000000..02109d29e4 --- /dev/null +++ b/examples/linearlite/src/assets/icons/label.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/menu.svg b/examples/linearlite/src/assets/icons/menu.svg new file mode 100644 index 0000000000..404573a3a2 --- /dev/null +++ b/examples/linearlite/src/assets/icons/menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/parent-issue.svg b/examples/linearlite/src/assets/icons/parent-issue.svg new file mode 100644 index 0000000000..23635e9199 --- /dev/null +++ b/examples/linearlite/src/assets/icons/parent-issue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/plus.svg b/examples/linearlite/src/assets/icons/plus.svg new file mode 100644 index 0000000000..d874b1c66d --- /dev/null +++ b/examples/linearlite/src/assets/icons/plus.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/project.svg b/examples/linearlite/src/assets/icons/project.svg new file mode 100644 index 0000000000..89e1f8cebe --- /dev/null +++ b/examples/linearlite/src/assets/icons/project.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/question.svg b/examples/linearlite/src/assets/icons/question.svg new file mode 100644 index 0000000000..c836426746 --- /dev/null +++ b/examples/linearlite/src/assets/icons/question.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/relationship.svg b/examples/linearlite/src/assets/icons/relationship.svg new file mode 100644 index 0000000000..cb0dc32c75 --- /dev/null +++ b/examples/linearlite/src/assets/icons/relationship.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/rounded-claim.svg b/examples/linearlite/src/assets/icons/rounded-claim.svg new file mode 100644 index 0000000000..0ed5d811ea --- /dev/null +++ b/examples/linearlite/src/assets/icons/rounded-claim.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/search.svg b/examples/linearlite/src/assets/icons/search.svg new file mode 100644 index 0000000000..01f82dfe48 --- /dev/null +++ b/examples/linearlite/src/assets/icons/search.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/signal-medium.svg b/examples/linearlite/src/assets/icons/signal-medium.svg new file mode 100644 index 0000000000..4aac5b2546 --- /dev/null +++ b/examples/linearlite/src/assets/icons/signal-medium.svg @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/signal-strong.svg b/examples/linearlite/src/assets/icons/signal-strong.svg new file mode 100644 index 0000000000..5d6f38ce7a --- /dev/null +++ b/examples/linearlite/src/assets/icons/signal-strong.svg @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/signal-strong.xsd b/examples/linearlite/src/assets/icons/signal-strong.xsd new file mode 100644 index 0000000000..da262c9b7e --- /dev/null +++ b/examples/linearlite/src/assets/icons/signal-strong.xsd @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/signal-weak.svg b/examples/linearlite/src/assets/icons/signal-weak.svg new file mode 100644 index 0000000000..a5a5a6b77d --- /dev/null +++ b/examples/linearlite/src/assets/icons/signal-weak.svg @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/slack.svg b/examples/linearlite/src/assets/icons/slack.svg new file mode 100644 index 0000000000..0aec65f54d --- /dev/null +++ b/examples/linearlite/src/assets/icons/slack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/view.svg b/examples/linearlite/src/assets/icons/view.svg new file mode 100644 index 0000000000..86bc611d47 --- /dev/null +++ b/examples/linearlite/src/assets/icons/view.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/examples/linearlite/src/assets/icons/zoom.svg b/examples/linearlite/src/assets/icons/zoom.svg new file mode 100644 index 0000000000..4090443261 --- /dev/null +++ b/examples/linearlite/src/assets/icons/zoom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/assets/images/icon.inverse.svg b/examples/linearlite/src/assets/images/icon.inverse.svg new file mode 100644 index 0000000000..142b79bdf4 --- /dev/null +++ b/examples/linearlite/src/assets/images/icon.inverse.svg @@ -0,0 +1,4 @@ + + + + diff --git a/examples/linearlite/src/assets/images/logo.svg b/examples/linearlite/src/assets/images/logo.svg new file mode 100644 index 0000000000..9dfc1c058c --- /dev/null +++ b/examples/linearlite/src/assets/images/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/linearlite/src/components/AboutModal.tsx b/examples/linearlite/src/components/AboutModal.tsx new file mode 100644 index 0000000000..21cdffac4a --- /dev/null +++ b/examples/linearlite/src/components/AboutModal.tsx @@ -0,0 +1,59 @@ +import Modal from './Modal' + +interface Props { + isOpen: boolean + onDismiss?: () => void +} + +export default function AboutModal({ isOpen, onDismiss }: Props) { + return ( + +
+

+ This is an example of a team collaboration app such as{` `} + + Linear + + {` `} + built using{` `} + + ElectricSQL + + {` `}- the local-first sync layer for web and mobile apps. +

+

+ This example is built on top of the excellent clone of the Linear UI + built by{` `} + + Tuan Nguyen + + . +

+

+ We have replaced the canned data with a stack running{` `} + + Electric + + {` `} + in Docker. +

+
+
+ ) +} diff --git a/examples/linearlite/src/components/Avatar.tsx b/examples/linearlite/src/components/Avatar.tsx new file mode 100644 index 0000000000..88bc0cd3a3 --- /dev/null +++ b/examples/linearlite/src/components/Avatar.tsx @@ -0,0 +1,84 @@ +import { MouseEventHandler } from 'react' +import classnames from 'classnames' +import AvatarImg from '../assets/icons/avatar.svg' + +interface Props { + online?: boolean + showOffline?: boolean + name?: string + avatarUrl?: string + onClick?: MouseEventHandler | undefined +} + +//bg-blue-500 + +function stringToHslColor(str: string, s: number, l: number) { + let hash = 0 + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash) + } + + const h = hash % 360 + return `hsl(` + h + `, ` + s + `%, ` + l + `%)` +} + +function getAcronym(name: string) { + let acr = ((name || ``).match(/\b(\w)/g) || []) + .join(``) + .slice(0, 2) + .toUpperCase() + if (acr.length === 1) { + acr = acr + name.slice(1, 2).toLowerCase() + } + return acr +} +function Avatar({ online, showOffline, name, onClick, avatarUrl }: Props) { + let avatar, status + + // create avatar image icon + if (avatarUrl) + avatar = ( + {name} + ) + else if (name !== undefined) { + // use name as avatar + avatar = ( +
+ {getAcronym(name)} +
+ ) + } else { + // try to use default avatar + avatar = ( + avatar + ) + } + + //status icon + if (online || showOffline) + status = ( + // + + ) + else status = null + + return ( +
+ {avatar} + {status} +
+ ) +} + +export default Avatar diff --git a/examples/linearlite/src/components/IssueModal.tsx b/examples/linearlite/src/components/IssueModal.tsx new file mode 100644 index 0000000000..1c09f17143 --- /dev/null +++ b/examples/linearlite/src/components/IssueModal.tsx @@ -0,0 +1,196 @@ +import { memo, useEffect, useRef, useState } from 'react' +import { generateKeyBetween } from 'fractional-indexing' + +import { BsChevronRight as ChevronRight } from 'react-icons/bs' +import { ReactComponent as CloseIcon } from '../assets/icons/close.svg' +import { ReactComponent as ElectricIcon } from '../assets/images/icon.inverse.svg' + +import Modal from '../components/Modal' +import Editor from '../components/editor/Editor' +import PriorityIcon from './PriorityIcon' +import StatusIcon from './StatusIcon' +import PriorityMenu from './contextmenu/PriorityMenu' +import StatusMenu from './contextmenu/StatusMenu' + +import { + Priority, + Status, + PriorityDisplay, + StatusValue, + PriorityValue, +} from '../types/types' +import { showInfo, showWarning } from '../utils/notification' +import { usePGlite } from '@electric-sql/pglite-react' +import { Issue } from '../types/types' +import config from '../config' + +interface Props { + isOpen: boolean + onDismiss?: () => void +} + +function IssueModal({ isOpen, onDismiss }: Props) { + const ref = useRef(null) + const [title, setTitle] = useState(``) + const [description, setDescription] = useState() + const [priority, setPriority] = useState(Priority.NONE) + const [status, setStatus] = useState(Status.BACKLOG) + const pg = usePGlite() + + const handleSubmit = async () => { + if (title === '') { + showWarning('Please enter a title before submitting', 'Title required') + return + } + + if (config.readonly) { + showWarning('This is a read-only demo', 'Read-only') + if (onDismiss) onDismiss() + reset() + return + } + + const lastIssue = ( + await pg.query(` + SELECT * FROM issue + ORDER BY kanbanorder DESC + LIMIT 1 + `) + )?.rows[0] + const kanbanorder = generateKeyBetween(lastIssue?.kanbanorder, null) + + const date = new Date() + await pg.sql` + INSERT INTO issue (id, title, username, priority, status, description, modified, created, kanbanorder) + VALUES (${crypto.randomUUID()}, ${title}, ${'testuser'}, ${priority}, ${status}, ${description ?? ''}, ${date}, ${date}, ${kanbanorder}) + ` + + if (onDismiss) onDismiss() + reset() + showInfo(`You created new issue.`, `Issue created`) + } + + const handleClickCloseBtn = () => { + if (onDismiss) onDismiss() + reset() + } + + const reset = () => { + setTimeout(() => { + setTitle(``) + setDescription(``) + setPriority(Priority.NONE) + setStatus(Status.BACKLOG) + }, 250) + } + + const timeoutRef = useRef>() + + useEffect(() => { + if (isOpen && !timeoutRef.current) { + // eslint-disable-next-line @eslint-react/web-api/no-leaked-timeout + timeoutRef.current = setTimeout(() => { + ref.current?.focus() + timeoutRef.current = undefined + }, 250) + } + }, [isOpen]) + + const body = ( +
+ {/* header */} +
+
+ + + electric + + + New Issue +
+
+ +
+
+
+ {/* Issue title */} +
+ + + + } + onSelect={(st) => { + setStatus(st as StatusValue) + }} + /> + setTitle(e.target.value)} + /> +
+ + {/* Issue description editor */} +
+ setDescription(val)} + placeholder="Add description..." + /> +
+
+ + {/* Issue labels & priority */} +
+ + + {PriorityDisplay[priority]} + + } + onSelect={(val) => { + setPriority(val as PriorityValue) + }} + /> +
+ {/* Footer */} +
+ +
+
+ ) + + return ( + + {body} + + ) +} + +export default memo(IssueModal) diff --git a/examples/linearlite/src/components/ItemGroup.tsx b/examples/linearlite/src/components/ItemGroup.tsx new file mode 100644 index 0000000000..61e62384d5 --- /dev/null +++ b/examples/linearlite/src/components/ItemGroup.tsx @@ -0,0 +1,28 @@ +import { BsFillCaretDownFill, BsFillCaretRightFill } from 'react-icons/bs' +import * as React from 'react' +import { useState } from 'react' + +interface Props { + title: string + children: React.ReactNode +} +function ItemGroup({ title, children }: Props) { + const [showItems, setShowItems] = useState(true) + + const Icon = showItems ? BsFillCaretDownFill : BsFillCaretRightFill + return ( +
+ + {showItems && children} +
+ ) +} + +export default ItemGroup diff --git a/examples/linearlite/src/components/LeftMenu.tsx b/examples/linearlite/src/components/LeftMenu.tsx new file mode 100644 index 0000000000..df167c84a4 --- /dev/null +++ b/examples/linearlite/src/components/LeftMenu.tsx @@ -0,0 +1,195 @@ +import { ReactComponent as HelpIcon } from '../assets/icons/help.svg' +import { ReactComponent as MenuIcon } from '../assets/icons/menu.svg' +import { ReactComponent as ElectricIcon } from '../assets/images/icon.inverse.svg' +import { ReactComponent as BacklogIcon } from '../assets/icons/circle-dot.svg' +import { MenuContext } from '../App' +import classnames from 'classnames' +import { memo, RefObject, useRef, useState, useContext } from 'react' +import { BsPencilSquare as AddIcon } from 'react-icons/bs' +import { BsSearch as SearchIcon } from 'react-icons/bs' +import { BsFillGrid3X3GapFill as BoardIcon } from 'react-icons/bs' +import { BsCollectionFill as IssuesIcon } from 'react-icons/bs' +import { MdKeyboardArrowDown as ExpandMore } from 'react-icons/md' +import { BsTerminalFill as ConsoleIcon } from 'react-icons/bs' +import { Link } from 'react-router-dom' +import Avatar from './Avatar' +import AboutModal from './AboutModal' +import IssueModal from './IssueModal' +import PGliteConsoleModal from './PGliteConsoleModal' +import ItemGroup from './ItemGroup' +import ProfileMenu from './ProfileMenu' + +function LeftMenu() { + const ref = useRef() as RefObject + const [showProfileMenu, setShowProfileMenu] = useState(false) + const [showAboutModal, setShowAboutModal] = useState(false) + const [showIssueModal, setShowIssueModal] = useState(false) + const [showPGliteConsoleModal, setShowPGliteConsoleModal] = useState(false) + const { showMenu, setShowMenu } = useContext(MenuContext)! + + const classes = classnames( + `absolute z-40 lg:static inset-0 transform duration-300 lg:relative lg:translate-x-0 bg-white flex flex-col flex-shrink-0 w-56 font-sans text-sm text-gray-700 border-r border-gray-100 lg:shadow-none justify-items-start`, + { + '-translate-x-full ease-out shadow-none': !showMenu, + 'translate-x-0 ease-in shadow-xl': showMenu, + } + ) + + return ( + <> +
+ + + {/* Top menu*/} +
+
+ {/* Project selection */} + + + {/*
+ G +
*/} + electric + + + {/* User avatar */} +
+ + setShowProfileMenu(false)} + setShowAboutModal={setShowAboutModal} + className="absolute top-10" + /> +
+
+ + {/* Create issue btn */} +
+ + + + +
+
+ +
+ + + + All Issues + + + + + + Active + + + + Backlog + + + + Board + + + + {/* extra space */} +
+ + {/* bottom group */} +
+ + + + {` `} + ElectricSQL + + +
+
+
+ {/* Modals */} + { + setShowAboutModal(false)} + /> + } + { + setShowIssueModal(false)} + /> + } + { + setShowPGliteConsoleModal(false)} + /> + } + + ) +} + +export default memo(LeftMenu) diff --git a/examples/linearlite/src/components/Modal.tsx b/examples/linearlite/src/components/Modal.tsx new file mode 100644 index 0000000000..3f69409c01 --- /dev/null +++ b/examples/linearlite/src/components/Modal.tsx @@ -0,0 +1,103 @@ +import React, { + memo, + RefObject, + useCallback, + useRef, + type MouseEvent, +} from 'react' +import ReactDOM from 'react-dom' +import classnames from 'classnames' + +import { ReactComponent as CloseIcon } from '../assets/icons/close.svg' +import useLockBodyScroll from '../hooks/useLockBodyScroll' +import { Transition } from '@headlessui/react' + +interface Props { + title?: string + isOpen: boolean + center?: boolean + className?: string + /* function called when modal is closed */ + onDismiss?: () => void + children?: React.ReactNode + size?: keyof typeof sizeClasses +} +const sizeClasses = { + large: `w-175`, + normal: `w-140`, +} + +function Modal({ + title, + isOpen, + center = true, + size = `normal`, + className, + onDismiss, + children, +}: Props) { + const ref = useRef(null) as RefObject + const outerRef = useRef(null) + + const wrapperClasses = classnames( + `fixed flex flex-col items-center inset-0 z-50`, + { + 'justify-center': center, + } + ) + const modalClasses = classnames( + `flex flex-col items-center overflow-hidden transform bg-white modal shadow-large-modal rounded-xl`, + { + 'mt-20 mb-2 ': !center, + }, + sizeClasses[size], + className + ) + const handleClick = useCallback( + (event: MouseEvent) => { + event.stopPropagation() + event.preventDefault() + if (!onDismiss) return + if (ref.current && !ref.current.contains(event.target as Element)) { + onDismiss() + } + }, + [onDismiss] + ) + + useLockBodyScroll() + + const modal = ( +
+ +
+ {title && ( +
+
{title}
+
+ +
+
+ )} + {children} +
+
+
+ ) + + return ReactDOM.createPortal( + modal, + document.getElementById(`root-modal`) as Element + ) +} + +export default memo(Modal) diff --git a/examples/linearlite/src/components/PGliteConsoleModal.tsx b/examples/linearlite/src/components/PGliteConsoleModal.tsx new file mode 100644 index 0000000000..f2f0184204 --- /dev/null +++ b/examples/linearlite/src/components/PGliteConsoleModal.tsx @@ -0,0 +1,22 @@ +import { Repl } from '@electric-sql/pglite-repl' +import Modal from './Modal' + +interface Props { + isOpen: boolean + onDismiss?: () => void +} + +export default function PGliteConsoleModal({ isOpen, onDismiss }: Props) { + return ( + +
+ +
+
+ ) +} diff --git a/examples/linearlite/src/components/Portal.tsx b/examples/linearlite/src/components/Portal.tsx new file mode 100644 index 0000000000..e6e29d4849 --- /dev/null +++ b/examples/linearlite/src/components/Portal.tsx @@ -0,0 +1,15 @@ +import { ReactNode, useState } from 'react' +import { useEffect } from 'react' +import { createPortal } from 'react-dom' + +//Copied from https://github.com/tailwindlabs/headlessui/blob/71730fea1291e572ae3efda16d8644f870d87750/packages/%40headlessui-react/pages/menu/menu-with-popper.tsx#L90 +export function Portal(props: { children: ReactNode }) { + const { children } = props + const [mounted, setMounted] = useState(false) + + // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect + useEffect(() => setMounted(true), []) + + if (!mounted) return null + return createPortal(children, document.body) +} diff --git a/examples/linearlite/src/components/PriorityIcon.tsx b/examples/linearlite/src/components/PriorityIcon.tsx new file mode 100644 index 0000000000..c1105530cc --- /dev/null +++ b/examples/linearlite/src/components/PriorityIcon.tsx @@ -0,0 +1,13 @@ +import classNames from 'classnames' +import { PriorityIcons, PriorityValue } from '../types/types' + +interface Props { + priority: string + className?: string +} + +export default function PriorityIcon({ priority, className }: Props) { + const classes = classNames(`w-4 h-4`, className) + const Icon = PriorityIcons[priority.toLowerCase() as PriorityValue] + return +} diff --git a/examples/linearlite/src/components/ProfileMenu.tsx b/examples/linearlite/src/components/ProfileMenu.tsx new file mode 100644 index 0000000000..cfc5322e3c --- /dev/null +++ b/examples/linearlite/src/components/ProfileMenu.tsx @@ -0,0 +1,99 @@ +import { Transition } from '@headlessui/react' +import { useRef } from 'react' +import classnames from 'classnames' +import { useClickOutside } from '../hooks/useClickOutside' +import Toggle from './Toggle' + +interface Props { + isOpen: boolean + onDismiss?: () => void + setShowAboutModal?: (show: boolean) => void + className?: string +} +export default function ProfileMenu({ + isOpen, + className, + onDismiss, + setShowAboutModal, +}: Props) { + const connectivityState = { status: `disconnected` } + const classes = classnames( + `select-none w-53 shadow-modal z-50 flex flex-col py-1 bg-white font-normal rounded text-gray-800`, + className + ) + const ref = useRef(null) + + const connectivityConnected = connectivityState.status !== `disconnected` + const connectivityStateDisplay = + connectivityState.status[0].toUpperCase() + + connectivityState.status.slice(1) + + // const toggleConnectivityState = () => { + // if (connectivityConnected) { + // electric.disconnect() + // } else { + // electric.connect() + // } + // } + + useClickOutside(ref, () => { + if (isOpen && onDismiss) { + onDismiss() + } + }) + + return ( +
+ + + + Visit ElectricSQL + + + Documentation + + + GitHub + +
+ + {connectivityStateDisplay} + + +
+
+
+ ) +} diff --git a/examples/linearlite/src/components/Select.tsx b/examples/linearlite/src/components/Select.tsx new file mode 100644 index 0000000000..e142cd8cb2 --- /dev/null +++ b/examples/linearlite/src/components/Select.tsx @@ -0,0 +1,29 @@ +import classNames from 'classnames' +import { ReactNode } from 'react' + +interface Props { + className?: string + children: ReactNode + defaultValue?: string | number | ReadonlyArray + value?: string | number | ReadonlyArray + onChange?: (event: React.ChangeEvent) => void +} +export default function Select(props: Props) { + const { children, defaultValue, className, value, onChange, ...rest } = props + + const classes = classNames( + `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`, + className + ) + return ( + + ) +} diff --git a/examples/linearlite/src/components/StatusIcon.tsx b/examples/linearlite/src/components/StatusIcon.tsx new file mode 100644 index 0000000000..d6c066a1a4 --- /dev/null +++ b/examples/linearlite/src/components/StatusIcon.tsx @@ -0,0 +1,15 @@ +import classNames from 'classnames' +import { StatusIcons, StatusValue } from '../types/types' + +interface Props { + status: string + className?: string +} + +export default function StatusIcon({ status, className }: Props) { + const classes = classNames(`w-3.5 h-3.5 rounded`, className) + + const Icon = StatusIcons[status.toLowerCase() as StatusValue] + + return +} diff --git a/examples/linearlite/src/components/Toggle.tsx b/examples/linearlite/src/components/Toggle.tsx new file mode 100644 index 0000000000..4494b98cdd --- /dev/null +++ b/examples/linearlite/src/components/Toggle.tsx @@ -0,0 +1,41 @@ +import classnames from 'classnames' + +interface Props { + onChange?: (value: boolean) => void + className?: string + value?: boolean + activeClass?: string + activeLabelClass?: string +} +export default function Toggle({ + onChange, + className, + value = false, + activeClass = `bg-indigo-600 hover:bg-indigo-700`, + activeLabelClass = `border-indigo-600`, +}: Props) { + const labelClasses = classnames( + `absolute h-3.5 w-3.5 overflow-hidden border-2 transition duration-200 ease-linear rounded-full cursor-pointer bg-white`, + { + 'left-0 border-gray-300': !value, + 'right-0': value, + [activeLabelClass]: value, + } + ) + const classes = classnames( + `group relative rounded-full w-5 h-3.5 transition duration-200 ease-linear`, + { + [activeClass]: value, + 'bg-gray-300': !value, + }, + className + ) + const onClick = () => { + if (onChange) onChange(!value) + } + return ( +
+ +
+ ) +} diff --git a/examples/linearlite/src/components/TopFilter.tsx b/examples/linearlite/src/components/TopFilter.tsx new file mode 100644 index 0000000000..5724b4e2ee --- /dev/null +++ b/examples/linearlite/src/components/TopFilter.tsx @@ -0,0 +1,216 @@ +import { ReactComponent as MenuIcon } from '../assets/icons/menu.svg' +import { useState, useContext, useEffect } from 'react' +import { BsSortUp, BsPlus, BsX, BsSearch as SearchIcon } from 'react-icons/bs' +import { useLiveQuery, usePGlite } from '@electric-sql/pglite-react' +import ViewOptionMenu from './ViewOptionMenu' +import { MenuContext } from '../App' +import FilterMenu from './contextmenu/FilterMenu' +import { FilterState, useFilterState } from '../utils/filterState' +import { PriorityDisplay, StatusDisplay } from '../types/types' +import debounce from 'lodash.debounce' +import { createFTSIndex } from '../migrations' + +interface Props { + filteredIssuesCount: number + hideSort?: boolean + showSearch?: boolean + title?: string + filterState?: FilterState +} + +export default function ({ + filteredIssuesCount, + hideSort, + showSearch, + title = `All issues`, + filterState, +}: Props) { + const [usedFilterState, setFilterState] = useFilterState() + const [showViewOption, setShowViewOption] = useState(false) + const { showMenu, setShowMenu } = useContext(MenuContext)! + const [searchQuery, setSearchQuery] = useState(``) + const [FTSIndexReady, setFTSIndexReady] = useState(true) + const pg = usePGlite() + + filterState ??= usedFilterState + + const totalIssuesCount = useLiveQuery<{ count: number }>( + `SELECT COUNT(id) FROM issue WHERE deleted = false` + )?.rows[0].count + + const handleSearchInner = debounce((query: string) => { + setFilterState({ + ...filterState, + query: query, + }) + }, 300) + + const handleSearch = (query: string) => { + setSearchQuery(query) + handleSearchInner(query) + } + + const eqStatuses = (statuses: string[]) => { + const statusSet = new Set(statuses) + return ( + filterState.status?.length === statusSet.size && + filterState.status.every((x) => statusSet.has(x)) + ) + } + + if (filterState.status?.length) { + if (eqStatuses([`backlog`])) { + title = `Backlog` + } else if (eqStatuses([`todo`, `in_progress`])) { + title = `Active` + } + } + + useEffect(() => { + if (!showSearch) return + const checkFTSIndex = async () => { + const res = await pg.query( + `SELECT 1 FROM pg_indexes WHERE indexname = 'issue_search_idx';` + ) + const indexReady = res.rows.length > 0 + if (!indexReady) { + setFTSIndexReady(false) + await createFTSIndex(pg) + } + setFTSIndexReady(true) + } + checkFTSIndex() + }, [showSearch, pg]) + + const showFTSIndexProgress = showSearch && !FTSIndexReady + + return ( + <> +
+ {/* left section */} +
+ + +
{title}
+ {/* {filteredIssuesCount} */} + + {filteredIssuesCount.toLocaleString()} + {totalIssuesCount !== undefined && + filteredIssuesCount !== totalIssuesCount + ? ` of ${totalIssuesCount.toLocaleString()}` + : ``} + + + + Filter + + } + id={`filter-menu`} + /> +
+ +
+ {!hideSort && ( + + )} +
+
+ + {(!!filterState.status?.length || !!filterState.priority?.length) && ( +
+ {!!filterState.priority?.length && ( +
+ Priority is + + {filterState.priority + ?.map( + (priority) => + PriorityDisplay[priority as keyof typeof PriorityDisplay] + ) + .join(`, `)} + + { + setFilterState({ + ...filterState, + priority: undefined, + }) + }} + > + + +
+ )} + {!!filterState.status?.length && ( +
+ Status is + + {filterState.status + ?.map( + (status) => + StatusDisplay[status as keyof typeof StatusDisplay] + ) + .join(`, `)} + + { + setFilterState({ + ...filterState, + status: undefined, + }) + }} + > + + +
+ )} +
+ )} + + {showSearch && ( +
+ + handleSearch(e.target.value)} + /> +
+ )} + + {showFTSIndexProgress && ( +
+
+
+ Building full text search index... (only happens once) +
+
+ )} + + setShowViewOption(false)} + /> + + ) +} diff --git a/examples/linearlite/src/components/ViewOptionMenu.tsx b/examples/linearlite/src/components/ViewOptionMenu.tsx new file mode 100644 index 0000000000..a464927b48 --- /dev/null +++ b/examples/linearlite/src/components/ViewOptionMenu.tsx @@ -0,0 +1,92 @@ +import { Transition } from '@headlessui/react' +import { useClickOutside } from '../hooks/useClickOutside' +import { useRef } from 'react' +import Select from './Select' +import { useFilterState } from '../utils/filterState' + +interface Props { + isOpen: boolean + onDismiss?: () => void +} +export default function ({ isOpen, onDismiss }: Props) { + const ref = useRef(null) + const [filterState, setFilterState] = useFilterState() + + useClickOutside(ref, () => { + if (isOpen && onDismiss) onDismiss() + }) + + const handleOrderByChange = (e: React.ChangeEvent) => { + setFilterState({ + ...filterState, + orderBy: e.target.value, + }) + } + + const handleOrderDirectionChange = ( + e: React.ChangeEvent + ) => { + setFilterState({ + ...filterState, + orderDirection: e.target.value as `asc` | `desc`, + }) + } + + return ( +
+ +
+ View Options +
+ +
+ {/*
+ Grouping +
+ +
+
*/} + +
+ Ordering +
+ +
+
+ +
+
+
+
+
+ ) +} diff --git a/examples/linearlite/src/components/contextmenu/FilterMenu.tsx b/examples/linearlite/src/components/contextmenu/FilterMenu.tsx new file mode 100644 index 0000000000..886235ce98 --- /dev/null +++ b/examples/linearlite/src/components/contextmenu/FilterMenu.tsx @@ -0,0 +1,121 @@ +import { Portal } from '../Portal' +import { ReactNode, useState } from 'react' +import { ContextMenuTrigger } from '@firefox-devtools/react-contextmenu' +import { BsCheck2 } from 'react-icons/bs' +import { Menu } from './menu' +import { useFilterState } from '../../utils/filterState' +import { PriorityOptions, StatusOptions } from '../../types/types' + +interface Props { + id: string + button: ReactNode + className?: string +} + +function FilterMenu({ id, button, className }: Props) { + const [filterState, setFilterState] = useFilterState() + const [keyword, setKeyword] = useState(``) + + let priorities = PriorityOptions + if (keyword !== ``) { + const normalizedKeyword = keyword.toLowerCase().trim() + priorities = priorities.filter( + ([_icon, _priority, label]) => + (label as string).toLowerCase().indexOf(normalizedKeyword) !== -1 + ) + } + + let statuses = StatusOptions + if (keyword !== ``) { + const normalizedKeyword = keyword.toLowerCase().trim() + statuses = statuses.filter( + ([_icon, _status, label]) => + label.toLowerCase().indexOf(normalizedKeyword) !== -1 + ) + } + + const priorityOptions = priorities.map(([Icon, priority, label]) => { + return ( + handlePrioritySelect(priority as string)} + > + + {label} + {filterState.priority?.includes(priority) && ( + + )} + + ) + }) + + const statusOptions = statuses.map(([Icon, status, label]) => { + return ( + handleStatusSelect(status as string)} + > + + {label} + {filterState.status?.includes(status) && ( + + )} + + ) + }) + + const handlePrioritySelect = (priority: string) => { + setKeyword(``) + const newPriority = filterState.priority || [] + if (newPriority.includes(priority)) { + newPriority.splice(newPriority.indexOf(priority), 1) + } else { + newPriority.push(priority) + } + setFilterState({ + ...filterState, + priority: newPriority, + }) + } + + const handleStatusSelect = (status: string) => { + setKeyword(``) + const newStatus = filterState.status || [] + if (newStatus.includes(status)) { + newStatus.splice(newStatus.indexOf(status), 1) + } else { + newStatus.push(status) + } + setFilterState({ + ...filterState, + status: newStatus, + }) + } + + return ( + <> + + {button} + + + + setKeyword(kw)} + > + {priorityOptions && Priority} + {priorityOptions} + {priorityOptions && statusOptions && } + {statusOptions && Status} + {statusOptions} + + + + ) +} + +export default FilterMenu diff --git a/examples/linearlite/src/components/contextmenu/PriorityMenu.tsx b/examples/linearlite/src/components/contextmenu/PriorityMenu.tsx new file mode 100644 index 0000000000..f297164117 --- /dev/null +++ b/examples/linearlite/src/components/contextmenu/PriorityMenu.tsx @@ -0,0 +1,70 @@ +import { Portal } from '../Portal' +import { ReactNode, useState } from 'react' +import { ContextMenuTrigger } from '@firefox-devtools/react-contextmenu' +import { Menu } from './menu' +import { PriorityOptions } from '../../types/types' + +interface Props { + id: string + button: ReactNode + filterKeyword?: boolean + className?: string + onSelect?: (item: string) => void +} + +function PriorityMenu({ + id, + button, + filterKeyword = false, + className, + onSelect, +}: Props) { + const [keyword, setKeyword] = useState(``) + + const handleSelect = (priority: string) => { + setKeyword(``) + if (onSelect) onSelect(priority) + } + let statusOpts = PriorityOptions + if (keyword !== ``) { + const normalizedKeyword = keyword.toLowerCase().trim() + statusOpts = statusOpts.filter( + ([_Icon, _priority, label]) => + (label as string).toLowerCase().indexOf(normalizedKeyword) !== -1 + ) + } + + const options = statusOpts.map(([Icon, priority, label]) => { + return ( + handleSelect(priority as string)} + > + {label} + + ) + }) + + return ( + <> + + {button} + + + + setKeyword(kw)} + className={className} + > + {options} + + + + ) +} + +export default PriorityMenu diff --git a/examples/linearlite/src/components/contextmenu/StatusMenu.tsx b/examples/linearlite/src/components/contextmenu/StatusMenu.tsx new file mode 100644 index 0000000000..8ab3faa539 --- /dev/null +++ b/examples/linearlite/src/components/contextmenu/StatusMenu.tsx @@ -0,0 +1,56 @@ +import { Portal } from '../Portal' +import { ReactNode, useState } from 'react' +import { ContextMenuTrigger } from '@firefox-devtools/react-contextmenu' +import { StatusOptions } from '../../types/types' +import { Menu } from './menu' + +interface Props { + id: string + button: ReactNode + className?: string + onSelect?: (status: string) => void +} +export default function StatusMenu({ id, button, className, onSelect }: Props) { + const [keyword, setKeyword] = useState(``) + const handleSelect = (status: string) => { + if (onSelect) onSelect(status) + } + + let statuses = StatusOptions + if (keyword !== ``) { + const normalizedKeyword = keyword.toLowerCase().trim() + statuses = statuses.filter( + ([_icon, _id, l]) => l.toLowerCase().indexOf(normalizedKeyword) !== -1 + ) + } + + const options = statuses.map(([Icon, id, label]) => { + return ( + handleSelect(id)}> + +
{label}
+
+ ) + }) + + return ( + <> + + {button} + + + + setKeyword(kw)} + > + {options} + + + + ) +} diff --git a/examples/linearlite/src/components/contextmenu/menu.tsx b/examples/linearlite/src/components/contextmenu/menu.tsx new file mode 100644 index 0000000000..f4eff45a57 --- /dev/null +++ b/examples/linearlite/src/components/contextmenu/menu.tsx @@ -0,0 +1,103 @@ +import classnames from 'classnames' +import { ReactNode, useRef } from 'react' +import { + ContextMenu, + MenuItem, + type MenuItemProps as CMMenuItemProps, +} from '@firefox-devtools/react-contextmenu' + +const sizeClasses = { + small: `w-34`, + normal: `w-72`, +} + +export interface MenuProps { + id: string + size: keyof typeof sizeClasses + className?: string + onKeywordChange?: (kw: string) => void + filterKeyword: boolean + children: ReactNode + searchPlaceholder?: string +} + +interface MenuItemProps { + children: ReactNode + onClick?: CMMenuItemProps[`onClick`] +} +const Item = function ({ onClick, children }: MenuItemProps) { + return ( + + {children} + + ) +} + +const Divider = function () { + return +} + +const Header = function ({ children }: MenuItemProps) { + return ( + + {children} + + ) +} + +export const Menu = (props: MenuProps) => { + const { + id, + size = `small`, + onKeywordChange, + children, + className, + filterKeyword, + searchPlaceholder, + } = props + const ref = useRef(null) + + const classes = classnames( + `cursor-default bg-white rounded shadow-modal z-100`, + sizeClasses[size], + className + ) + + return ( + { + if (ref.current) ref.current.focus() + }} + > +
{ + e.stopPropagation() + }} + > + {filterKeyword && ( + { + if (onKeywordChange) onKeywordChange(e.target.value) + }} + onClick={(e) => { + e.stopPropagation() + }} + placeholder={searchPlaceholder} + /> + )} + {children} +
+
+ ) +} + +Menu.Item = Item +Menu.Divider = Divider +Menu.Header = Header diff --git a/examples/linearlite/src/components/editor/Editor.tsx b/examples/linearlite/src/components/editor/Editor.tsx new file mode 100644 index 0000000000..bf5b2bb932 --- /dev/null +++ b/examples/linearlite/src/components/editor/Editor.tsx @@ -0,0 +1,82 @@ +import { + useEditor, + EditorContent, + BubbleMenu, + type Extensions, +} from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import Placeholder from '@tiptap/extension-placeholder' +import Table from '@tiptap/extension-table' +import TableCell from '@tiptap/extension-table-cell' +import TableHeader from '@tiptap/extension-table-header' +import TableRow from '@tiptap/extension-table-row' +import { Markdown } from 'tiptap-markdown' +import EditorMenu from './EditorMenu' +import { useEffect, useRef } from 'react' + +interface EditorProps { + value: string + onChange: (value: string) => void + className?: string + placeholder?: string +} + +const Editor = ({ + value, + onChange, + className = ``, + placeholder, +}: EditorProps) => { + const editorProps = { + attributes: { + class: className, + }, + } + const markdownValue = useRef(null) + + const extensions: Extensions = [ + StarterKit, + Markdown, + Table, + TableRow, + TableHeader, + TableCell, + ] + + const editor = useEditor({ + extensions, + editorProps, + content: value || undefined, + onUpdate: ({ editor }) => { + markdownValue.current = editor.storage.markdown.getMarkdown() + onChange(markdownValue.current || ``) + }, + }) + + useEffect(() => { + if (editor && markdownValue.current !== value) { + editor.commands.setContent(value) + } + }, [editor, value]) + + if (placeholder) { + extensions.push( + Placeholder.configure({ + placeholder, + }) + ) + } + + return ( + <> + + {editor && ( + + + + )} + + ) +} + +export default Editor diff --git a/examples/linearlite/src/components/editor/EditorMenu.tsx b/examples/linearlite/src/components/editor/EditorMenu.tsx new file mode 100644 index 0000000000..de8bd22e60 --- /dev/null +++ b/examples/linearlite/src/components/editor/EditorMenu.tsx @@ -0,0 +1,125 @@ +import type { Editor as TipTapEditor } from '@tiptap/react' +import classNames from 'classnames' + +import { BsTypeBold as BoldIcon } from 'react-icons/bs' +import { BsTypeItalic as ItalicIcon } from 'react-icons/bs' +import { BsTypeStrikethrough as StrikeIcon } from 'react-icons/bs' +import { BsCode as CodeIcon } from 'react-icons/bs' +import { BsListUl as BulletListIcon } from 'react-icons/bs' +import { BsListOl as OrderedListIcon } from 'react-icons/bs' +import { BsCodeSlash as CodeBlockIcon } from 'react-icons/bs' +import { BsChatQuote as BlockquoteIcon } from 'react-icons/bs' + +export interface EditorMenuProps { + editor: TipTapEditor +} + +const EditorMenu = ({ editor }: EditorMenuProps) => { + return ( +
+ + + + +
+ + + + +
+ ) +} + +export default EditorMenu diff --git a/examples/linearlite/src/config.ts b/examples/linearlite/src/config.ts new file mode 100644 index 0000000000..9e8fcb4e6a --- /dev/null +++ b/examples/linearlite/src/config.ts @@ -0,0 +1,3 @@ +export default { + readonly: false, +} diff --git a/examples/linearlite/src/electric.tsx b/examples/linearlite/src/electric.tsx new file mode 100644 index 0000000000..5c85f02c5b --- /dev/null +++ b/examples/linearlite/src/electric.tsx @@ -0,0 +1 @@ +export const baseUrl = import.meta.env.ELECTRIC_URL ?? `http://localhost:3000` diff --git a/examples/linearlite/src/hooks/useClickOutside.ts b/examples/linearlite/src/hooks/useClickOutside.ts new file mode 100644 index 0000000000..a7f6a94ac2 --- /dev/null +++ b/examples/linearlite/src/hooks/useClickOutside.ts @@ -0,0 +1,28 @@ +import { RefObject, useCallback, useEffect } from 'react' + +export const useClickOutside = ( + ref: RefObject, + callback: (event: MouseEvent | TouchEvent) => void, + outerRef?: RefObject +): void => { + const handleClick = useCallback( + (event: MouseEvent | TouchEvent) => { + if (!event.target || outerRef?.current?.contains(event.target as Node)) { + return + } + if (ref.current && !ref.current.contains(event.target as Node)) { + callback(event) + } + }, + [callback, ref, outerRef] + ) + useEffect(() => { + document.addEventListener(`mousedown`, handleClick) + document.addEventListener(`touchstart`, handleClick) + + return () => { + document.removeEventListener(`mousedown`, handleClick) + document.removeEventListener(`touchstart`, handleClick) + } + }) +} diff --git a/examples/linearlite/src/hooks/useLockBodyScroll.ts b/examples/linearlite/src/hooks/useLockBodyScroll.ts new file mode 100644 index 0000000000..4bd0a19331 --- /dev/null +++ b/examples/linearlite/src/hooks/useLockBodyScroll.ts @@ -0,0 +1,14 @@ +import { useLayoutEffect } from 'react' + +export default function useLockBodyScroll() { + useLayoutEffect(() => { + // Get original value of body overflow + const originalStyle = window.getComputedStyle(document.body).overflow + // Prevent scrolling on mount + document.body.style.overflow = `hidden` + // Re-enable scrolling when component unmounts + return () => { + document.body.style.overflow = originalStyle + } + }, []) // Empty array ensures effect is only run on mount and unmount +} diff --git a/examples/linearlite/src/main.tsx b/examples/linearlite/src/main.tsx new file mode 100644 index 0000000000..45cb8857d4 --- /dev/null +++ b/examples/linearlite/src/main.tsx @@ -0,0 +1,18 @@ +import { createRoot } from 'react-dom/client' +import './style.css' + +import App from './App' + +const container = document.getElementById(`root`)! +const root = createRoot(container) +root.render() + +// If you want to start measuring performance in your app, pass a function +// to log results (for example: reportWebVitals(console.log)) +// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +//reportWebVitals(); + +// If you want your app to work offline and load faster, you can change +// unregister() to register() below. Note this comes with some pitfalls. +// Learn more about service workers: https://cra.link/PWA +//serviceWorkerRegistration.register(); diff --git a/examples/linearlite/src/migrations.ts b/examples/linearlite/src/migrations.ts new file mode 100644 index 0000000000..143a6fb48b --- /dev/null +++ b/examples/linearlite/src/migrations.ts @@ -0,0 +1,30 @@ +import type { PGlite, PGliteInterface } from '@electric-sql/pglite' +import m1 from '../db/migrations-client/01-create_tables.sql?raw' +import postInitialSyncIndexes from '../db/migrations-client/post-initial-sync-indexes.sql?raw' +import postInitialSyncFtsIndex from '../db/migrations-client/post-initial-sync-fts-index.sql?raw' + +export async function migrate(pg: PGlite) { + const tables = await pg.query( + `SELECT table_name FROM information_schema.tables WHERE table_schema='public'` + ) + if (tables.rows.length === 0) { + await pg.exec(m1) + } +} + +export async function postInitialSync(pg: PGliteInterface) { + const commands = postInitialSyncIndexes + .split('\n') + .map((c) => c.trim()) + .filter((c) => c.length > 0) + for (const command of commands) { + // wait 100ms between commands + console.time(`command: ${command}`) + await pg.exec(command) + console.timeEnd(`command: ${command}`) + } +} + +export async function createFTSIndex(pg: PGliteInterface) { + await pg.exec(postInitialSyncFtsIndex) +} diff --git a/examples/linearlite/src/pages/Board/IssueBoard.tsx b/examples/linearlite/src/pages/Board/IssueBoard.tsx new file mode 100644 index 0000000000..919b88d970 --- /dev/null +++ b/examples/linearlite/src/pages/Board/IssueBoard.tsx @@ -0,0 +1,248 @@ +import { DragDropContext, DropResult } from 'react-beautiful-dnd' +import { useState, useEffect } from 'react' +import { generateKeyBetween } from 'fractional-indexing' +import { Issue, Status, StatusDisplay, StatusValue } from '../../types/types' +import { useLiveQuery, usePGlite } from '@electric-sql/pglite-react' +import IssueCol from './IssueCol' +import { LiveQuery, LiveQueryResults } from '@electric-sql/pglite/live' + +export interface IssueBoardProps { + columnsLiveIssues: Record> +} + +interface MovedIssues { + [id: string]: { + status?: StatusValue + kanbanorder?: string + } +} + +export default function IssueBoard({ columnsLiveIssues }: IssueBoardProps) { + const pg = usePGlite() + const [movedIssues, setMovedIssues] = useState({}) + + // Issues are coming from a live query, this may not have updated before we rerender + // after a drag and drop. So we keep track of moved issues and use that to override + // the status of the issue when sorting the issues into columns. + + useEffect(() => { + // Reset moved issues when issues change + // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect + setMovedIssues({}) + }, [columnsLiveIssues]) + + const issuesByStatus: Record = {} + const issuesResByStatus: Record> = {} + Object.entries(columnsLiveIssues).forEach(([status, liveQuery]) => { + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/rules-of-hooks + const issuesRes = useLiveQuery(liveQuery) + issuesResByStatus[status] = issuesRes + issuesRes.rows.forEach((issue) => { + // If the issue has been moved, patch with new status and kanbanorder for sorting + if (movedIssues[issue.id]) { + issue = { + ...issue, + ...movedIssues[issue.id], + } + } + + const status = issue.status.toLowerCase() + if (!issuesByStatus[status]) { + issuesByStatus[status] = [] + } + issuesByStatus[status].push(issue) + }) + }) + + // Sort issues in each column by kanbanorder and issue id + Object.keys(issuesByStatus).forEach((status) => { + issuesByStatus[status].sort((a, b) => { + if (a.kanbanorder < b.kanbanorder) { + return -1 + } + if (a.kanbanorder > b.kanbanorder) { + return 1 + } + // Use unique issue id to break ties + if (a.id < b.id) { + return -1 + } else { + return 1 + } + }) + }) + + // Fill in undefined to create the leader and tail for the offset and limit + Object.keys(issuesByStatus).forEach((status) => { + // new array of lenth issuesResByStatus[status].total_count + const issues = new Array(issuesResByStatus[status]?.totalCount || 0) + const offset = issuesResByStatus[status]?.offset || 0 + issuesByStatus[status].forEach((issue, index) => { + issues[index + offset] = issue + }) + issuesByStatus[status] = issues + }) + + const adjacentIssues = ( + column: string, + index: number, + sameColumn = true, + currentIndex: number + ) => { + const columnIssues = issuesByStatus[column] || [] + let prevIssue: Issue | undefined + let nextIssue: Issue | undefined + if (sameColumn) { + if (currentIndex < index) { + prevIssue = columnIssues[index] + nextIssue = columnIssues[index + 1] + } else { + prevIssue = columnIssues[index - 1] + nextIssue = columnIssues[index] + } + } else { + prevIssue = columnIssues[index - 1] + nextIssue = columnIssues[index] + } + console.log(`sameColumn`, sameColumn) + console.log(`prevIssue`, prevIssue) + console.log(`nextIssue`, nextIssue) + return { prevIssue, nextIssue } + } + + // /** + // * Fix duplicate kanbanorder, this is recursive so we can fix multiple consecutive + // * issues with the same kanbanorder. + // * @param issue The issue to fix the kanbanorder for + // * @param issueBefore The issue immediately before one that needs fixing + // * @returns The new kanbanorder that was set for the issue + // */ + // const fixKanbanOrder = (issue: Issue, issueBefore: Issue) => { + // // First we find the issue immediately after the issue that needs fixing. + // const issueIndex = issuesByStatus[issue.status].indexOf(issue) + // const issueAfter = issuesByStatus[issue.status][issueIndex + 1] + + // // The kanbanorder of the issue before the issue that needs fixing + // const prevKanbanOrder = issueBefore?.kanbanorder + + // // The kanbanorder of the issue after the issue that needs fixing + // let nextKanbanOrder = issueAfter?.kanbanorder + + // // If the next issue has the same kanbanorder the next issue needs fixing too, + // // we recursively call fixKanbanOrder for that issue to fix it's kanbanorder. + // if (issueAfter && nextKanbanOrder && nextKanbanOrder === prevKanbanOrder) { + // nextKanbanOrder = fixKanbanOrder(issueAfter, issueBefore) + // } + + // // Generate a new kanbanorder between the previous and next issues + // const kanbanorder = generateKeyBetween(prevKanbanOrder, nextKanbanOrder) + + // // Keep track of moved issues so we can override the kanbanorder when sorting + // // We do this due to the momentary lag between updating the database and the live + // // query updating the issues. + // setMovedIssues((prev) => ({ + // ...prev, + // [issue.id]: { + // kanbanorder: kanbanorder, + // }, + // })) + + // pg.sql` + // UPDATE issue + // SET kanbanorder = ${kanbanorder} + // WHERE id = ${issue.id} + // ` + + // // Return the new kanbanorder + // return kanbanorder + // } + + /** + * Get a new kanbanorder that sits between two other issues. + * Used to generate a new kanbanorder when moving an issue. + * @param issueBefore The issue immediately before the issue being moved + * @param issueAfter The issue immediately after the issue being moved + * @returns The new kanbanorder + */ + const getNewKanbanOrder = (issueBefore: Issue, issueAfter: Issue) => { + const prevKanbanOrder = issueBefore?.kanbanorder + // eslint-disable-next-line prefer-const + let nextKanbanOrder = issueAfter?.kanbanorder + // if (nextKanbanOrder && nextKanbanOrder === prevKanbanOrder) { + // // If the next issue has the same kanbanorder as the previous issue, + // // we need to fix the kanbanorder of the next issue. + // // This can happen when two users move issues into the same position at the same + // // time. + // nextKanbanOrder = fixKanbanOrder(issueAfter, issueBefore) + // } + return generateKeyBetween(prevKanbanOrder, nextKanbanOrder) + } + + const onDragEnd = ({ source, destination, draggableId }: DropResult) => { + console.log(source, destination, draggableId) + if (destination && destination.droppableId) { + const { prevIssue, nextIssue } = adjacentIssues( + destination.droppableId, + destination.index, + destination.droppableId === source.droppableId, + source.index + ) + // Get a new kanbanorder between the previous and next issues + const kanbanorder = getNewKanbanOrder(prevIssue, nextIssue) + // Keep track of moved issues so we can override the status and kanbanorder when + // sorting issues into columns. + const modified = new Date() + setMovedIssues((prev) => ({ + ...prev, + [draggableId]: { + status: destination.droppableId as StatusValue, + kanbanorder, + modified, + }, + })) + pg.sql` + UPDATE issue + SET status = ${destination.droppableId}, kanbanorder = ${kanbanorder}, modified = ${modified} + WHERE id = ${draggableId} + ` + } + } + + return ( + +
+ + + + + +
+
+ ) +} diff --git a/examples/linearlite/src/pages/Board/IssueCol.tsx b/examples/linearlite/src/pages/Board/IssueCol.tsx new file mode 100644 index 0000000000..e0c37d5e3d --- /dev/null +++ b/examples/linearlite/src/pages/Board/IssueCol.tsx @@ -0,0 +1,155 @@ +import { CSSProperties } from 'react' +import StatusIcon from '../../components/StatusIcon' +import { + Droppable, + DroppableProvided, + DroppableStateSnapshot, + Draggable, + DraggableProvided, + DraggableStateSnapshot, +} from 'react-beautiful-dnd' +import { FixedSizeList as List, ListOnItemsRenderedProps } from 'react-window' +import AutoSizer from 'react-virtualized-auto-sizer' +import IssueItem, { itemHeight } from './IssueItem' +import { Issue } from '../../types/types' +import { LiveQuery } from '@electric-sql/pglite/live' +import { useLiveQuery } from '@electric-sql/pglite-react' + +const CHUNK_SIZE = 25 + +function calculateWindow( + startIndex: number, + stopIndex: number +): { offset: number; limit: number } { + const offset = Math.max( + 0, + Math.floor(startIndex / CHUNK_SIZE) * CHUNK_SIZE - CHUNK_SIZE + ) + const endOffset = Math.ceil(stopIndex / CHUNK_SIZE) * CHUNK_SIZE + CHUNK_SIZE + const limit = endOffset - offset + return { offset, limit } +} + +interface Props { + status: string + title: string + issues: Array | undefined + liveQuery: LiveQuery +} + +const itemSpacing = 8 + +function IssueCol({ title, status, issues, liveQuery }: Props) { + issues = issues || [] + const statusIcon = + + const issuesRes = useLiveQuery(liveQuery) + + const offset = liveQuery.initialResults.offset ?? issuesRes.offset ?? 0 + const limit = liveQuery.initialResults.limit ?? issuesRes.limit ?? CHUNK_SIZE + + const handleOnItemsRendered = (props: ListOnItemsRenderedProps) => { + const { offset: newOffset, limit: newLimit } = calculateWindow( + props.overscanStartIndex, + props.overscanStopIndex + ) + if (newOffset !== offset || newLimit !== limit) { + liveQuery.refresh({ offset: newOffset, limit: newLimit }) + } + } + + return ( +
+
+
+ {statusIcon} + {title} + + {issues?.length || 0} + +
+
+ { + const issue = issues[rubric.source.index] + return ( + + ) + }} + > + {( + droppableProvided: DroppableProvided, + snapshot: DroppableStateSnapshot + ) => { + // Add an extra item to our list to make space for a dragging item + // Usually the DroppableProvided.placeholder does this, but that won't + // work in a virtual list + const itemCount: number = snapshot.isUsingPlaceholder + ? issues.length + 1 + : issues.length + + return ( +
+ + {({ height, width }) => ( + + {Row} + + )} + +
+ ) + }} +
+
+ ) +} + +const Row = ({ + data: issues, + index, + style, +}: { + data: Issue[] + index: number + style: CSSProperties | undefined +}) => { + const issue = issues[index] + if (!issue) return null + return ( + + {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( + + )} + + ) +} + +export default IssueCol diff --git a/examples/linearlite/src/pages/Board/IssueItem.tsx b/examples/linearlite/src/pages/Board/IssueItem.tsx new file mode 100644 index 0000000000..aaa32b5a1c --- /dev/null +++ b/examples/linearlite/src/pages/Board/IssueItem.tsx @@ -0,0 +1,92 @@ +import { type CSSProperties } from 'react' +import classNames from 'classnames' +import { useNavigate } from 'react-router-dom' +import { DraggableProvided } from 'react-beautiful-dnd' +import { BsCloudCheck as SyncedIcon } from 'react-icons/bs' +import { BsCloudSlash as UnsyncedIcon } from 'react-icons/bs' +import { usePGlite } from '@electric-sql/pglite-react' +import Avatar from '../../components/Avatar' +import PriorityMenu from '../../components/contextmenu/PriorityMenu' +import PriorityIcon from '../../components/PriorityIcon' +import { Issue } from '../../types/types' + +interface IssueProps { + issue: Issue + index: number + isDragging?: boolean + provided: DraggableProvided + style?: CSSProperties +} + +export const itemHeight = 100 + +function getStyle( + provided: DraggableProvided, + style?: CSSProperties +): CSSProperties { + return { + ...provided.draggableProps.style, + ...(style || {}), + height: `${itemHeight}px`, + } +} + +const IssueItem = ({ issue, style, isDragging, provided }: IssueProps) => { + const pg = usePGlite() + const navigate = useNavigate() + const priorityIcon = ( + + + + ) + + const updatePriority = (priority: string) => { + pg.sql` + UPDATE issue + SET priority = ${priority}, modified = ${new Date()} + WHERE id = ${issue.id} + ` + } + + return ( +
navigate(`/issue/${issue.id}`)} + > +
+
+ + {issue.title} + +
+
+ +
+
+
+ updatePriority(p)} + /> + {issue.synced ? ( + + ) : ( + + )} +
+
+ ) +} + +export default IssueItem diff --git a/examples/linearlite/src/pages/Board/index.tsx b/examples/linearlite/src/pages/Board/index.tsx new file mode 100644 index 0000000000..41dd826539 --- /dev/null +++ b/examples/linearlite/src/pages/Board/index.tsx @@ -0,0 +1,40 @@ +/* eslint-disable react-compiler/react-compiler */ +import TopFilter from '../../components/TopFilter' +import IssueBoard from './IssueBoard' +import { FilterState } from '../../utils/filterState' +import { Issue, StatusValue } from '../../types/types' +import { useLiveQuery } from '@electric-sql/pglite-react' +import { useLoaderData } from 'react-router-dom' +import { LiveQuery } from '@electric-sql/pglite/live' + +export interface BoardLoaderData { + filterState: FilterState + columnsLiveIssues: Record> +} + +function Board() { + const { columnsLiveIssues, filterState } = useLoaderData() as BoardLoaderData + + const totalIssuesCount = Object.values(columnsLiveIssues).reduce( + (total, liveQuery) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const issuesRes = useLiveQuery(liveQuery) + return total + (issuesRes?.totalCount ?? 0) + }, + 0 + ) + + return ( +
+ + +
+ ) +} + +export default Board diff --git a/examples/linearlite/src/pages/Issue/Comments.tsx b/examples/linearlite/src/pages/Issue/Comments.tsx new file mode 100644 index 0000000000..8245ecc6b9 --- /dev/null +++ b/examples/linearlite/src/pages/Issue/Comments.tsx @@ -0,0 +1,103 @@ +import { useState } from 'react' +import ReactMarkdown from 'react-markdown' +import Editor from '../../components/editor/Editor' +import Avatar from '../../components/Avatar' +import { formatDate } from '../../utils/date' +import { showWarning } from '../../utils/notification' +import { Comment, Issue } from '../../types/types' +import { BsCloudCheck as SyncedIcon } from 'react-icons/bs' +import { BsCloudSlash as UnsyncedIcon } from 'react-icons/bs' +import { useLiveQuery, usePGlite } from '@electric-sql/pglite-react' + +export interface CommentsProps { + issue: Issue +} + +function Comments({ issue }: CommentsProps) { + const pg = usePGlite() + const [newCommentBody, setNewCommentBody] = useState(``) + const commentsResults = useLiveQuery.sql` + SELECT * FROM comment WHERE issue_id = ${issue.id} + ` + const comments = commentsResults?.rows + + const commentList = () => { + if (comments && comments.length > 0) { + return comments.map((comment) => ( +
+
+ + + {comment.username} + + + {formatDate(comment.created)} + + + {/* Synced status */} + {comment.synced ? ( + + ) : ( + + )} + +
+
+ {comment.body} +
+
+ )) + } + } + + const handlePost = () => { + if (!newCommentBody) { + showWarning( + `Please enter a comment before submitting`, + `Comment required` + ) + return + } + + pg.sql` + INSERT INTO comment (id, issue_id, body, created, username) + VALUES ( + ${crypto.randomUUID()}, + ${issue.id}, + ${newCommentBody}, + ${new Date()}, + ${'testuser'} + ) + ` + + setNewCommentBody(``) + } + + return ( + <> + {commentList()} +
+ setNewCommentBody(val)} + placeholder="Add a comment..." + /> +
+
+ +
+ + ) +} + +export default Comments diff --git a/examples/linearlite/src/pages/Issue/DeleteModal.tsx b/examples/linearlite/src/pages/Issue/DeleteModal.tsx new file mode 100644 index 0000000000..6fcd50b83e --- /dev/null +++ b/examples/linearlite/src/pages/Issue/DeleteModal.tsx @@ -0,0 +1,48 @@ +import Modal from '../../components/Modal' + +interface Props { + isOpen: boolean + setIsOpen: (isOpen: boolean) => void + onDismiss?: () => void + deleteIssue: () => void +} + +export default function AboutModal({ + isOpen, + setIsOpen, + onDismiss, + deleteIssue, +}: Props) { + const handleDelete = () => { + setIsOpen(false) + if (onDismiss) onDismiss() + deleteIssue() + } + + return ( + +
+ Are you sure you want to delete this issue? +
+
+ + +
+
+ ) +} diff --git a/examples/linearlite/src/pages/Issue/index.tsx b/examples/linearlite/src/pages/Issue/index.tsx new file mode 100644 index 0000000000..4144383451 --- /dev/null +++ b/examples/linearlite/src/pages/Issue/index.tsx @@ -0,0 +1,272 @@ +/* eslint-disable react-compiler/react-compiler */ +import { useNavigate, useLoaderData } from 'react-router-dom' +import { useState, useRef, useCallback } from 'react' +import { BsCloudCheck as SyncedIcon } from 'react-icons/bs' +import { BsCloudSlash as UnsyncedIcon } from 'react-icons/bs' +import { LiveQuery } from '@electric-sql/pglite/live' +import { usePGlite, useLiveQuery } from '@electric-sql/pglite-react' +import { BsTrash3 as DeleteIcon } from 'react-icons/bs' +import { BsXLg as CloseIcon } from 'react-icons/bs' +import PriorityMenu from '../../components/contextmenu/PriorityMenu' +import StatusMenu from '../../components/contextmenu/StatusMenu' +import PriorityIcon from '../../components/PriorityIcon' +import StatusIcon from '../../components/StatusIcon' +import Avatar from '../../components/Avatar' +import { Issue, PriorityDisplay, StatusDisplay } from '../../types/types' +import Editor from '../../components/editor/Editor' +import DeleteModal from './DeleteModal' +import Comments from './Comments' +import debounce from 'lodash.debounce' + +const debounceTime = 500 + +function IssuePage() { + const { liveIssue } = useLoaderData() as { liveIssue: LiveQuery } + const issue = useLiveQuery(liveIssue).rows[0] + const navigate = useNavigate() + const pg = usePGlite() + + const [showDeleteModal, setShowDeleteModal] = useState(false) + + const [dirtyTitle, setDirtyTitle] = useState(null) + const titleIsDirty = useRef(false) + const [dirtyDescription, setDirtyDescription] = useState(null) + const descriptionIsDirty = useRef(false) + + // eslint-disable-next-line react-hooks/exhaustive-deps + const handleTitleChangeDebounced = useCallback( + debounce(async (title: string) => { + console.log(`handleTitleChangeDebounced`, title) + pg.sql` + UPDATE issue + SET title = ${title}, modified = ${new Date()} + WHERE id = ${issue.id} + ` + // We can't set titleIsDirty.current = false here because we haven't yet received + // the updated issue from the db + }, debounceTime), + [pg] + ) + + // eslint-disable-next-line react-hooks/exhaustive-deps + const handleDescriptionChangeDebounced = useCallback( + debounce(async (description: string) => { + pg.sql` + UPDATE issue + SET description = ${description}, modified = ${new Date()} + WHERE id = ${issue.id} + ` + // We can't set descriptionIsDirty.current = false here because we haven't yet received + // the updated issue from the db + }, debounceTime), + [pg] + ) + + if (issue === undefined) { + return
Loading...
+ } else if (issue === null) { + return
Issue not found
+ } + + // We check if the dirty title or description is the same as the actual title or + // description, and if so, we can switch back to the non-dirty version + if (dirtyTitle === issue.title) { + setDirtyTitle(null) + titleIsDirty.current = false + } + if (dirtyDescription === issue.description) { + setDirtyDescription(null) + descriptionIsDirty.current = false + } + + const handleStatusChange = (status: string) => { + pg.sql` + UPDATE issue + SET status = ${status}, modified = ${new Date()} + WHERE id = ${issue.id} + ` + } + + const handlePriorityChange = (priority: string) => { + pg.sql` + UPDATE issue + SET priority = ${priority}, modified = ${new Date()} + WHERE id = ${issue.id} + ` + } + + const handleTitleChange = (title: string) => { + setDirtyTitle(title) + titleIsDirty.current = true + // We debounce the title change so that we don't spam the db with updates + handleTitleChangeDebounced(title) + } + + const handleDescriptionChange = (description: string) => { + setDirtyDescription(description) + descriptionIsDirty.current = true + // We debounce the description change so that we don't spam the db with updates + handleDescriptionChangeDebounced(description) + } + + const handleDelete = () => { + pg.sql` + DELETE FROM issue WHERE id = ${issue.id} + ` + // Comments will be deleted automatically because of the ON DELETE CASCADE + handleClose() + } + + const handleClose = () => { + if (window.history.length > 2) { + navigate(-1) + } + navigate(`/`) + } + + const shortId = () => { + if (issue.id.includes(`-`)) { + return issue.id.slice(issue.id.length - 8) + } else { + return issue.id + } + } + + return ( + <> +
+
+
+
+ Issue + + {shortId()} + +
+ +
+ {issue.synced ? ( + + ) : ( + + )} + + +
+
+
+ + {/*
issue info
*/} +
+
+
+
+
+ Opened by +
+
+ +
+
+
+
+ Status +
+
+ + + {StatusDisplay[issue.status]} + + } + onSelect={handleStatusChange} + /> +
+
+
+
+ Priority +
+
+ + + {PriorityDisplay[issue.priority]} + + } + onSelect={handlePriorityChange} + /> +
+
+
+
+
+ handleTitleChange(e.target.value)} + /> + +
+ handleDescriptionChange(val)} + placeholder="Add description..." + /> +
+
+

Comments

+ +
+
+
+
+ + setShowDeleteModal(false)} + deleteIssue={handleDelete} + /> + + ) +} + +export default IssuePage diff --git a/examples/linearlite/src/pages/List/IssueList.tsx b/examples/linearlite/src/pages/List/IssueList.tsx new file mode 100644 index 0000000000..7248ed8fef --- /dev/null +++ b/examples/linearlite/src/pages/List/IssueList.tsx @@ -0,0 +1,60 @@ +import { CSSProperties } from 'react' +import { + FixedSizeList as List, + areEqual, + type ListOnItemsRenderedProps, +} from 'react-window' +import { memo } from 'react' +import AutoSizer from 'react-virtualized-auto-sizer' +import IssueRow from './IssueRow' +import { Issue } from '../../types/types' + +export interface IssueListProps { + issues: (Issue | undefined)[] + onItemsRendered?: (props: ListOnItemsRenderedProps) => void +} + +function IssueList({ issues, onItemsRendered }: IssueListProps) { + return ( +
+ + {({ height, width }) => ( + + {VirtualIssueRow} + + )} + +
+ ) +} + +const VirtualIssueRow = memo( + ({ + data: issues, + index, + style, + }: { + data: (Issue | undefined)[] + index: number + style: CSSProperties + }) => { + const issue = issues[index] + return ( + + ) + }, + areEqual +) + +export default memo(IssueList) diff --git a/examples/linearlite/src/pages/List/IssueRow.tsx b/examples/linearlite/src/pages/List/IssueRow.tsx new file mode 100644 index 0000000000..05d5f45b53 --- /dev/null +++ b/examples/linearlite/src/pages/List/IssueRow.tsx @@ -0,0 +1,93 @@ +import type { CSSProperties } from 'react' +import { usePGlite } from '@electric-sql/pglite-react' +import { BsCloudCheck as SyncedIcon } from 'react-icons/bs' +import { BsCloudSlash as UnsyncedIcon } from 'react-icons/bs' +import PriorityMenu from '../../components/contextmenu/PriorityMenu' +import StatusMenu from '../../components/contextmenu/StatusMenu' +import PriorityIcon from '../../components/PriorityIcon' +import StatusIcon from '../../components/StatusIcon' +import Avatar from '../../components/Avatar' +import { memo } from 'react' +import { useNavigate } from 'react-router-dom' +import { formatDate } from '../../utils/date' +import { Issue } from '../../types/types' + +interface Props { + issue: Issue | undefined + style: CSSProperties +} + +function IssueRow({ issue, style }: Props) { + const pg = usePGlite() + const navigate = useNavigate() + + const handleChangeStatus = (status: string) => { + pg.sql` + UPDATE issue + SET status = ${status}, modified = ${new Date()} + WHERE id = ${issue!.id} + ` + } + + const handleChangePriority = (priority: string) => { + pg.sql` + UPDATE issue + SET priority = ${priority}, modified = ${new Date()} + WHERE id = ${issue!.id} + ` + } + + if (!issue?.id) { + return ( +
+
+
+ ) + } + + return ( +
navigate(`/issue/${issue.id}`)} + style={style} + > +
+ } + onSelect={handleChangePriority} + /> +
+
+ } + onSelect={handleChangeStatus} + /> +
+
+ {issue.title.slice(0, 3000) || ``} +
+
+ {formatDate(issue.created)} +
+
+ +
+
+ {issue.synced ? ( + + ) : ( + + )} +
+
+ ) +} + +export default memo(IssueRow) diff --git a/examples/linearlite/src/pages/List/index.tsx b/examples/linearlite/src/pages/List/index.tsx new file mode 100644 index 0000000000..b2a4970012 --- /dev/null +++ b/examples/linearlite/src/pages/List/index.tsx @@ -0,0 +1,67 @@ +import { useLiveQuery } from '@electric-sql/pglite-react' +import { LiveQuery } from '@electric-sql/pglite/live' +import { useLoaderData } from 'react-router-dom' +import { type ListOnItemsRenderedProps } from 'react-window' +import TopFilter from '../../components/TopFilter' +import IssueList from './IssueList' +import { Issue } from '../../types/types' +import { FilterState } from '../../utils/filterState' +const CHUNK_SIZE = 50 + +function calculateWindow( + startIndex: number, + stopIndex: number +): { offset: number; limit: number } { + const offset = Math.max( + 0, + Math.floor(startIndex / CHUNK_SIZE) * CHUNK_SIZE - CHUNK_SIZE + ) + const endOffset = Math.ceil(stopIndex / CHUNK_SIZE) * CHUNK_SIZE + CHUNK_SIZE + const limit = endOffset - offset + return { offset, limit } +} + +function List({ showSearch = false }) { + const { liveIssues, filterState } = useLoaderData() as { + liveIssues: LiveQuery + filterState: FilterState + } + + const issuesRes = useLiveQuery(liveIssues) + const offset = liveIssues.initialResults.offset ?? issuesRes.offset ?? 0 + const limit = liveIssues.initialResults.limit ?? issuesRes.limit ?? CHUNK_SIZE + const issues = issuesRes?.rows + + const updateOffsetAndLimit = (itemsRendered: ListOnItemsRenderedProps) => { + const { offset: newOffset, limit: newLimit } = calculateWindow( + itemsRendered.overscanStartIndex, + itemsRendered.overscanStopIndex + ) + if (newOffset !== offset || newLimit !== limit) { + liveIssues.refresh({ offset: newOffset, limit: newLimit }) + } + } + + const currentTotalCount = issuesRes.totalCount ?? issuesRes.rows.length + const currentOffset = issuesRes.offset ?? 0 + const filledItems = new Array(currentTotalCount).fill(null) + issues.forEach((issue, index) => { + filledItems[index + currentOffset] = issue + }) + + return ( +
+ + updateOffsetAndLimit(itemsRendered)} + issues={filledItems} + /> +
+ ) +} + +export default List diff --git a/examples/linearlite/src/pages/root.tsx b/examples/linearlite/src/pages/root.tsx new file mode 100644 index 0000000000..77cd6c5e1f --- /dev/null +++ b/examples/linearlite/src/pages/root.tsx @@ -0,0 +1,31 @@ +import { Outlet } from 'react-router-dom' +import LeftMenu from '../components/LeftMenu' +import { cssTransition, ToastContainer } from 'react-toastify' + +const slideUp = cssTransition({ + enter: `animate__animated animate__slideInUp`, + exit: `animate__animated animate__slideOutDown`, +}) + +export default function Root() { + return ( +
+
+ + +
+ +
+ ) +} diff --git a/examples/linearlite/src/pglite-worker.ts b/examples/linearlite/src/pglite-worker.ts new file mode 100644 index 0000000000..598424e244 --- /dev/null +++ b/examples/linearlite/src/pglite-worker.ts @@ -0,0 +1,15 @@ +import { worker } from '@electric-sql/pglite/worker' +import { PGlite } from '@electric-sql/pglite' +import { migrate } from './migrations' + +worker({ + async init() { + const pg = await PGlite.create({ + dataDir: 'idb://linearlite2', + relaxedDurability: true, + }) + // Migrate the database to the latest schema + await migrate(pg) + return pg + }, +}) diff --git a/examples/linearlite/src/pglite.ts b/examples/linearlite/src/pglite.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/linearlite/src/shims/react-contextmenu.d.ts b/examples/linearlite/src/shims/react-contextmenu.d.ts new file mode 100644 index 0000000000..c580d66e6b --- /dev/null +++ b/examples/linearlite/src/shims/react-contextmenu.d.ts @@ -0,0 +1,107 @@ +// Copied here from the unreleased master branch of github.com/firefox-devtools/react-contextmenu +/* eslint-disable */ + +declare module '@firefox-devtools/react-contextmenu' { + import * as React from 'react' + + export interface ContextMenuProps { + id: string + data?: any + className?: string + hideOnLeave?: boolean + rtl?: boolean + onHide?: { (event: any): void } + onMouseLeave?: + | { + ( + event: React.MouseEvent, + data: Object, + target: HTMLElement + ): void + } + | Function + onShow?: { (event: any): void } + preventHideOnContextMenu?: boolean + preventHideOnResize?: boolean + preventHideOnScroll?: boolean + style?: React.CSSProperties + children?: React.ReactNode + } + + export interface ContextMenuTriggerProps { + id: string + attributes?: React.HTMLAttributes + collect?: { (data: any): any } + disable?: boolean + holdToDisplay?: number + renderTag?: React.ElementType + triggerOnLeftClick?: boolean + disableIfShiftIsPressed?: boolean + [key: string]: any + children?: React.ReactNode + } + + export interface MenuItemProps { + attributes?: React.HTMLAttributes + className?: string + data?: Object + disabled?: boolean + divider?: boolean + preventClose?: boolean + onClick?: + | { + ( + event: + | React.TouchEvent + | React.MouseEvent, + data: Object, + target: HTMLElement + ): void + } + | Function + children?: React.ReactNode + } + + export interface SubMenuProps { + title: React.ReactElement | React.ReactText + className?: string + disabled?: boolean + hoverDelay?: number + rtl?: boolean + preventCloseOnClick?: boolean + onClick?: + | { + ( + event: + | React.TouchEvent + | React.MouseEvent, + data: Object, + target: HTMLElement + ): void + } + | Function + children?: React.ReactNode + } + + export interface ConnectMenuProps { + id: string + trigger: any + } + + export const ContextMenu: React.ComponentClass + export const ContextMenuTrigger: React.ComponentClass + export const MenuItem: React.ComponentClass + export const SubMenu: React.ComponentClass + export function connectMenu

( + menuId: string + ): ( + Child: React.ComponentType

+ ) => React.ComponentType

+ export function showMenu(opts?: any, target?: HTMLElement): void + export function hideMenu(opts?: any, target?: HTMLElement): void +} + +declare module '@firefox-devtools/react-contextmenu/modules/actions' { + export function showMenu(opts?: any, target?: HTMLElement): void + export function hideMenu(opts?: any, target?: HTMLElement): void +} diff --git a/examples/linearlite/src/style.css b/examples/linearlite/src/style.css new file mode 100644 index 0000000000..9b39c3c85e --- /dev/null +++ b/examples/linearlite/src/style.css @@ -0,0 +1,73 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; +body { + font-size: 12px; + @apply font-medium text-gray-600; +} + +@font-face { + font-family: 'Inter UI'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: + url('assets/fonts/Inter-UI-Regular.woff2') format('woff2'), + url('assets/fonts/Inter-UI-Regular.woff') format('woff'); +} + +@font-face { + font-family: 'Inter UI'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: + url('assets/fonts/Inter-UI-Medium.woff2') format('woff2'), + url('assets/fonts/Inter-UI-Medium.woff') format('woff'); +} + +@font-face { + font-family: 'Inter UI'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: + url('assets/fonts/Inter-UI-SemiBold.woff2') format('woff2'), + url('assets/fonts/Inter-UI-SemiBold.woff') format('woff'); +} + +@font-face { + font-family: 'Inter UI'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: + url('assets/fonts/Inter-UI-ExtraBold.woff2') format('woff2'), + url('assets/fonts/Inter-UI-ExtraBold.woff') format('woff'); +} + +.modal { + max-width: calc(100vw - 32px); + max-height: calc(100vh - 32px); +} + +.editor ul { + list-style-type: circle; +} +.editor ol { + list-style-type: decimal; +} + +#root, +body, +html { + height: 100%; +} + +.tiptap p.is-editor-empty:first-child::before { + color: #adb5bd; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} diff --git a/examples/linearlite/src/sync.ts b/examples/linearlite/src/sync.ts new file mode 100644 index 0000000000..ea5a84f528 --- /dev/null +++ b/examples/linearlite/src/sync.ts @@ -0,0 +1,318 @@ +import { Mutex } from '@electric-sql/pglite' +import { type PGliteWithLive } from '@electric-sql/pglite/live' +import { type PGliteWithSync } from '@electric-sql/pglite-sync' +import type { IssueChange, CommentChange, ChangeSet } from './utils/changes' +import { postInitialSync } from './migrations' +import { useEffect, useState } from 'react' + +const WRITE_SERVER_URL = import.meta.env.VITE_WRITE_SERVER_URL + ? import.meta.env.VITE_WRITE_SERVER_URL + : `http://localhost:3001` +const ELECTRIC_URL = import.meta.env.VITE_ELECTRIC_URL + ? new URL(import.meta.env.VITE_ELECTRIC_URL).origin + : `http://localhost:3000` +const ELECTRIC_DATABASE_ID = import.meta.env.VITE_ELECTRIC_DATABASE_ID +const ELECTRIC_TOKEN = import.meta.env.VITE_ELECTRIC_TOKEN +const APPLY_CHANGES_URL = `${WRITE_SERVER_URL}/apply-changes` + +type SyncStatus = 'initial-sync' | 'done' + +type PGliteWithExtensions = PGliteWithLive & PGliteWithSync + +export async function startSync(pg: PGliteWithExtensions) { + await startSyncToDatabase(pg) + startWritePath(pg) +} + +async function startSyncToDatabase(pg: PGliteWithExtensions) { + // Check if there are any issues already in the database + const issues = await pg.query(`SELECT 1 FROM issue LIMIT 1`) + const hasIssuesAtStart = issues.rows.length > 0 + + let issueShapeInitialSyncDone = false + let commentShapeInitialSyncDone = false + let postInitialSyncDone = false + + if (!hasIssuesAtStart && !postInitialSyncDone) { + updateSyncStatus('initial-sync', 'Downloading shape data...') + } + + let postInitialSyncDoneResolver: () => void + const postInitialSyncDonePromise = new Promise((resolve) => { + postInitialSyncDoneResolver = resolve + }) + + const doPostInitialSync = async () => { + if ( + !hasIssuesAtStart && + issueShapeInitialSyncDone && + commentShapeInitialSyncDone && + !postInitialSyncDone + ) { + postInitialSyncDone = true + updateSyncStatus('initial-sync', 'Creating indexes...') + await postInitialSync(pg) + postInitialSyncDoneResolver() + } + } + + const issueUrl = new URL(`${ELECTRIC_URL}/v1/shape`) + if (ELECTRIC_TOKEN) { + issueUrl.searchParams.set('token', ELECTRIC_TOKEN) + } + + // Issues Sync + const issuesSync = await pg.sync.syncShapeToTable({ + shape: { + url: issueUrl.toString(), + params: { + table: 'issue', + database_id: ELECTRIC_DATABASE_ID, + }, + }, + table: 'issue', + primaryKey: ['id'], + shapeKey: 'issues', + commitGranularity: 'up-to-date', + useCopy: true, + onInitialSync: async () => { + issueShapeInitialSyncDone = true + await pg.exec(`ALTER TABLE issue ENABLE TRIGGER ALL`) + doPostInitialSync() + }, + }) + issuesSync.subscribe( + () => { + if (!hasIssuesAtStart && !postInitialSyncDone) { + updateSyncStatus('initial-sync', 'Inserting issues...') + } + }, + (error) => { + console.error('issuesSync error', error) + } + ) + + const commentUrl = new URL(`${ELECTRIC_URL}/v1/shape`) + if (ELECTRIC_TOKEN) { + commentUrl.searchParams.set('token', ELECTRIC_TOKEN) + } + + // Comments Sync + const commentsSync = await pg.sync.syncShapeToTable({ + shape: { + url: commentUrl.toString(), + params: { + table: 'comment', + database_id: ELECTRIC_DATABASE_ID, + }, + }, + table: 'comment', + primaryKey: ['id'], + shapeKey: 'comments', + commitGranularity: 'up-to-date', + useCopy: true, + onInitialSync: async () => { + commentShapeInitialSyncDone = true + await pg.exec(`ALTER TABLE comment ENABLE TRIGGER ALL`) + doPostInitialSync() + }, + }) + commentsSync.subscribe( + () => { + if (!hasIssuesAtStart && !postInitialSyncDone) { + updateSyncStatus('initial-sync', 'Inserting comments...') + } + }, + (error) => { + console.error('commentsSync error', error) + } + ) + + if (!hasIssuesAtStart) { + await postInitialSyncDonePromise + await pg.query(`SELECT 1;`) // Do a query to ensure PGlite is idle + } + updateSyncStatus('done') +} + +const syncMutex = new Mutex() + +async function startWritePath(pg: PGliteWithExtensions) { + // Use a live query to watch for changes to the local tables that need to be synced + pg.live.query<{ + issue_count: number + comment_count: number + }>( + ` + SELECT * FROM + (SELECT count(id) as issue_count FROM issue WHERE synced = false), + (SELECT count(id) as comment_count FROM comment WHERE synced = false) + `, + [], + async (results) => { + const { issue_count, comment_count } = results.rows[0] + if (issue_count > 0 || comment_count > 0) { + await syncMutex.acquire() + try { + doSyncToServer(pg) + } finally { + syncMutex.release() + } + } + } + ) +} + +// Call wrapped in mutex to prevent multiple syncs from happening at the same time +async function doSyncToServer(pg: PGliteWithExtensions) { + let issueChanges: IssueChange[] + let commentChanges: CommentChange[] + await pg.transaction(async (tx) => { + const issueRes = await tx.query(` + SELECT + id, + title, + description, + priority, + status, + modified, + created, + kanbanorder, + username, + modified_columns, + deleted, + new + FROM issue + WHERE synced = false AND sent_to_server = false + `) + const commentRes = await tx.query(` + SELECT + id, + body, + username, + issue_id, + modified, + created, + modified_columns, + deleted, + new + FROM comment + WHERE synced = false AND sent_to_server = false + `) + issueChanges = issueRes.rows + commentChanges = commentRes.rows + }) + const changeSet: ChangeSet = { + issues: issueChanges!, + comments: commentChanges!, + } + const response = await fetch(APPLY_CHANGES_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(changeSet), + }) + if (!response.ok) { + throw new Error('Failed to apply changes') + } + await pg.transaction(async (tx) => { + // Mark all changes as sent to server, but check that the modified timestamp + // has not changed in the meantime + + tx.exec('SET LOCAL electric.bypass_triggers = true') + + for (const issue of issueChanges!) { + await tx.query( + ` + UPDATE issue + SET sent_to_server = true + WHERE id = $1 AND modified = $2 + `, + [issue.id, issue.modified] + ) + } + + for (const comment of commentChanges!) { + await tx.query( + ` + UPDATE comment + SET sent_to_server = true + WHERE id = $1 AND modified = $2 + `, + [comment.id, comment.modified] + ) + } + }) +} + +export function updateSyncStatus(newStatus: SyncStatus, message?: string) { + localStorage.setItem('syncStatus', JSON.stringify([newStatus, message])) + // Fire a storage event on this tab as this doesn't happen automatically + window.dispatchEvent( + new StorageEvent('storage', { + key: 'syncStatus', + newValue: JSON.stringify([newStatus, message]), + }) + ) +} + +export function useSyncStatus() { + const currentSyncStatusJson = localStorage.getItem('syncStatus') + const currentSyncStatus: [SyncStatus, string] = currentSyncStatusJson + ? JSON.parse(currentSyncStatusJson) + : ['initial-sync', 'Starting sync...'] + const [syncStatus, setSyncStatus] = + useState<[SyncStatus, string]>(currentSyncStatus) + + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === 'syncStatus' && e.newValue) { + const [newStatus, message] = JSON.parse(e.newValue) + setSyncStatus([newStatus, message]) + } + } + + window.addEventListener('storage', handleStorageChange) + + return () => { + window.removeEventListener('storage', handleStorageChange) + } + }, []) + + return syncStatus +} + +let initialSyncDone = false + +export function waitForInitialSyncDone() { + return new Promise((resolve) => { + if (initialSyncDone) { + resolve() + return + } + const handleStorageChange = (e: StorageEvent) => { + if (e.key === 'syncStatus' && e.newValue) { + const [newStatus] = JSON.parse(e.newValue) + if (newStatus === 'done') { + window.removeEventListener('storage', handleStorageChange) + initialSyncDone = true + resolve() + } + } + } + + // Check current status first + const currentSyncStatusJson = localStorage.getItem('syncStatus') + const [currentStatus] = currentSyncStatusJson + ? JSON.parse(currentSyncStatusJson) + : ['initial-sync'] + + if (currentStatus === 'done') { + initialSyncDone = true + resolve() + } else { + window.addEventListener('storage', handleStorageChange) + } + }) +} diff --git a/examples/linearlite/src/types/types.ts b/examples/linearlite/src/types/types.ts new file mode 100644 index 0000000000..6f173d3556 --- /dev/null +++ b/examples/linearlite/src/types/types.ts @@ -0,0 +1,119 @@ +import type React from 'react' + +import { ReactComponent as CancelIcon } from '../assets/icons/cancel.svg' +import { ReactComponent as BacklogIcon } from '../assets/icons/circle-dot.svg' +import { ReactComponent as TodoIcon } from '../assets/icons/circle.svg' +import { ReactComponent as DoneIcon } from '../assets/icons/done.svg' +import { ReactComponent as InProgressIcon } from '../assets/icons/half-circle.svg' + +import { ReactComponent as HighPriorityIcon } from '../assets/icons/signal-strong.svg' +import { ReactComponent as LowPriorityIcon } from '../assets/icons/signal-weak.svg' +import { ReactComponent as MediumPriorityIcon } from '../assets/icons/signal-medium.svg' +import { ReactComponent as NoPriorityIcon } from '../assets/icons/dots.svg' +import { ReactComponent as UrgentPriorityIcon } from '../assets/icons/rounded-claim.svg' + +export type Issue = { + id: string + title: string + description: string + priority: (typeof Priority)[keyof typeof Priority] + status: (typeof Status)[keyof typeof Status] + modified: Date + created: Date + kanbanorder: string + username: string + synced: boolean +} + +export type Comment = { + id: string + body: string + username: string + issue_id: string + created: Date + synced: boolean +} + +export const Priority = { + NONE: `none`, + URGENT: `urgent`, + HIGH: `high`, + LOW: `low`, + MEDIUM: `medium`, +} as const + +export type PriorityValue = (typeof Priority)[keyof typeof Priority] + +export const PriorityDisplay = { + [Priority.NONE]: `None`, + [Priority.URGENT]: `Urgent`, + [Priority.HIGH]: `High`, + [Priority.LOW]: `Low`, + [Priority.MEDIUM]: `Medium`, +} + +export const PriorityIcons = { + [Priority.NONE]: NoPriorityIcon, + [Priority.URGENT]: UrgentPriorityIcon, + [Priority.HIGH]: HighPriorityIcon, + [Priority.MEDIUM]: MediumPriorityIcon, + [Priority.LOW]: LowPriorityIcon, +} + +export const PriorityOptions: [ + React.FunctionComponent>, + PriorityValue, + string, +][] = [ + [PriorityIcons[Priority.NONE], Priority.NONE, `None`], + [PriorityIcons[Priority.URGENT], Priority.URGENT, `Urgent`], + [PriorityIcons[Priority.HIGH], Priority.HIGH, `High`], + [PriorityIcons[Priority.MEDIUM], Priority.MEDIUM, `Medium`], + [PriorityIcons[Priority.LOW], Priority.LOW, `Low`], +] + +export const Status = { + BACKLOG: `backlog`, + TODO: `todo`, + IN_PROGRESS: `in_progress`, + DONE: `done`, + CANCELED: `canceled`, +} as const + +export type StatusValue = (typeof Status)[keyof typeof Status] + +export const StatusDisplay = { + [Status.BACKLOG]: `Backlog`, + [Status.TODO]: `To Do`, + [Status.IN_PROGRESS]: `In Progress`, + [Status.DONE]: `Done`, + [Status.CANCELED]: `Canceled`, +} + +export const StatusIcons = { + [Status.BACKLOG]: BacklogIcon, + [Status.TODO]: TodoIcon, + [Status.IN_PROGRESS]: InProgressIcon, + [Status.DONE]: DoneIcon, + [Status.CANCELED]: CancelIcon, +} + +export const StatusOptions: [ + React.FunctionComponent>, + StatusValue, + string, +][] = [ + [StatusIcons[Status.BACKLOG], Status.BACKLOG, StatusDisplay[Status.BACKLOG]], + [StatusIcons[Status.TODO], Status.TODO, StatusDisplay[Status.TODO]], + [ + StatusIcons[Status.IN_PROGRESS], + Status.IN_PROGRESS, + StatusDisplay[Status.IN_PROGRESS], + ], + [StatusIcons[Status.DONE], Status.DONE, StatusDisplay[Status.DONE]], + [ + StatusIcons[Status.CANCELED], + Status.CANCELED, + StatusDisplay[Status.CANCELED], + ], +] diff --git a/examples/linearlite/src/utils/changes.ts b/examples/linearlite/src/utils/changes.ts new file mode 100644 index 0000000000..c0a6aa48a0 --- /dev/null +++ b/examples/linearlite/src/utils/changes.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' + +export const issueChangeSchema = z.object({ + id: z.string(), + title: z.string().nullable().optional(), + description: z.string().nullable().optional(), + priority: z.string().nullable().optional(), + status: z.string().nullable().optional(), + modified: z.string().nullable().optional(), + created: z.string().nullable().optional(), + kanbanorder: z.string().nullable().optional(), + username: z.string().nullable().optional(), + modified_columns: z.array(z.string()).nullable().optional(), + deleted: z.boolean().nullable().optional(), + new: z.boolean().nullable().optional(), +}) + +export type IssueChange = z.infer + +export const commentChangeSchema = z.object({ + id: z.string(), + body: z.string().nullable().optional(), + username: z.string().nullable().optional(), + issue_id: z.string().nullable().optional(), + modified: z.string().nullable().optional(), + created: z.string().nullable().optional(), + modified_columns: z.array(z.string()).nullable().optional(), + deleted: z.boolean().nullable().optional(), + new: z.boolean().nullable().optional(), +}) + +export type CommentChange = z.infer + +export const changeSetSchema = z.object({ + issues: z.array(issueChangeSchema), + comments: z.array(commentChangeSchema), +}) + +export type ChangeSet = z.infer diff --git a/examples/linearlite/src/utils/date.ts b/examples/linearlite/src/utils/date.ts new file mode 100644 index 0000000000..367909624a --- /dev/null +++ b/examples/linearlite/src/utils/date.ts @@ -0,0 +1,6 @@ +import dayjs from 'dayjs' + +export function formatDate(date?: Date): string { + if (!date) return `` + return dayjs(date).format(`D MMM`) +} diff --git a/examples/linearlite/src/utils/filterState.ts b/examples/linearlite/src/utils/filterState.ts new file mode 100644 index 0000000000..490909c61b --- /dev/null +++ b/examples/linearlite/src/utils/filterState.ts @@ -0,0 +1,115 @@ +import { useSearchParams } from 'react-router-dom' + +export interface FilterState { + orderBy: string + orderDirection: `asc` | `desc` + status?: string[] + priority?: string[] + query?: string +} + +export function getFilterStateFromSearchParams( + searchParams: URLSearchParams +): FilterState { + const orderBy = searchParams.get(`orderBy`) ?? `created` + const orderDirection = + (searchParams.get(`orderDirection`) as `asc` | `desc`) ?? `desc` + const status = searchParams + .getAll(`status`) + .map((status) => status.toLocaleLowerCase().split(`,`)) + .flat() + const priority = searchParams + .getAll(`priority`) + .map((status) => status.toLocaleLowerCase().split(`,`)) + .flat() + const query = searchParams.get(`query`) + + const state = { + orderBy, + orderDirection, + status, + priority, + query: query || undefined, + } + + return state +} + +export function useFilterState(): [ + FilterState, + (state: Partial) => void, +] { + const [searchParams, setSearchParams] = useSearchParams() + const state = getFilterStateFromSearchParams(searchParams) + + const setState = (state: Partial) => { + const { orderBy, orderDirection, status, priority, query } = state + setSearchParams((searchParams) => { + if (orderBy) { + searchParams.set(`orderBy`, orderBy) + } else { + searchParams.delete(`orderBy`) + } + if (orderDirection) { + searchParams.set(`orderDirection`, orderDirection) + } else { + searchParams.delete(`orderDirection`) + } + if (status && status.length > 0) { + searchParams.set(`status`, status.join(`,`)) + } else { + searchParams.delete(`status`) + } + if (priority && priority.length > 0) { + searchParams.set(`priority`, priority.join(`,`)) + } else { + searchParams.delete(`priority`) + } + if (query) { + searchParams.set(`query`, query) + } else { + searchParams.delete(`query`) + } + return searchParams + }) + } + + return [state, setState] +} + +export function filterStateToSql(filterState: FilterState) { + let i = 1 + const sqlWhere = [] + const sqlParams = [] + if (filterState.status?.length) { + sqlWhere.push( + `status IN (${filterState.status.map(() => `$${i++}`).join(' ,')})` + ) + sqlParams.push(...filterState.status) + } + if (filterState.priority?.length) { + sqlWhere.push( + `priority IN (${filterState.priority.map(() => `$${i++}`).join(' ,')})` + ) + sqlParams.push(...filterState.priority) + } + if (filterState.query) { + sqlWhere.push(` + (setweight(to_tsvector('simple', coalesce(title, '')), 'A') || + setweight(to_tsvector('simple', coalesce(description, '')), 'B')) + @@ plainto_tsquery('simple', $${i++}) + `) + sqlParams.push(filterState.query) + } + const sql = ` + SELECT id, title, priority, status, modified, created, kanbanorder, username, synced + FROM issue + WHERE + ${sqlWhere.length ? `${sqlWhere.join(' AND ')} AND ` : ''} + deleted = false + ORDER BY + ${filterState.orderBy} ${filterState.orderDirection}, + id ASC + ` + return { sql, sqlParams } +} diff --git a/examples/linearlite/src/utils/notification.tsx b/examples/linearlite/src/utils/notification.tsx new file mode 100644 index 0000000000..cab562711a --- /dev/null +++ b/examples/linearlite/src/utils/notification.tsx @@ -0,0 +1,49 @@ +import { toast } from 'react-toastify' + +export function showWarning(msg: string, title: string = ``) { + //TODO: make notification showing from bottom + const content = ( +

+ {title !== `` && ( +
+ + + + + + {title} +
+ )} +
{msg}
+
+ ) + toast(content, { + position: `bottom-right`, + }) +} + +export function showInfo(msg: string, title: string = ``) { + //TODO: make notification showing from bottom + const content = ( +
+ {title !== `` && ( +
+ + + + + + {title} +
+ )} +
{msg}
+
+ ) + toast(content, { + position: `bottom-right`, + }) +} diff --git a/examples/linearlite/src/vite-env.d.ts b/examples/linearlite/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/linearlite/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/linearlite/sst-env.d.ts b/examples/linearlite/sst-env.d.ts new file mode 100644 index 0000000000..e973cf25d9 --- /dev/null +++ b/examples/linearlite/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +import "sst" +export {} +declare module "sst" { + export interface Resource { + } +} diff --git a/examples/linearlite/sst.config.ts b/examples/linearlite/sst.config.ts new file mode 100644 index 0000000000..1de835be3b --- /dev/null +++ b/examples/linearlite/sst.config.ts @@ -0,0 +1,98 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// + +import { execSync } from 'child_process' + +const isProduction = (stage: string) => + stage.toLocaleLowerCase() === `production` + +export default $config({ + app(input) { + return { + name: `linearlite`, + removal: isProduction(input?.stage) ? `retain` : `remove`, + home: `aws`, + providers: { + cloudflare: `5.42.0`, + aws: { + version: `6.57.0`, + }, + }, + } + }, + async run() { + try { + const databaseUri = $interpolate`postgresql://postgres:${process.env.LINEARLITE_SUPABASE_PROJECT_PASSWORD}@db.${process.env.LINEARLITE_SUPABASE_PROJECT_ID}.supabase.co:5432/postgres` + + databaseUri.apply(applyMigrations) + + const electricInfo = databaseUri.apply((uri) => + addDatabaseToElectric(uri) + ) + + if (!process.env.ELECTRIC_API) { + throw new Error(`ELECTRIC_API environment variable is required`) + } + + const website = new sst.aws.StaticSite('linearlite-website', { + build: { + command: 'npm run build', + output: 'dist', + }, + environment: { + VITE_ELECTRIC_URL: process.env.ELECTRIC_API, + VITE_ELECTRIC_TOKEN: electricInfo.token, + VITE_ELECTRIC_DATABASE_ID: electricInfo.id, + }, + domain: { + name: `linearlite${isProduction($app.stage) ? `` : `-stage-${$app.stage}`}.examples.electric-sql.com`, + dns: sst.cloudflare.dns(), + }, + dev: { + command: 'npm run vite', + }, + }) + + return { + databaseUri, + database_id: electricInfo.id, + electric_token: electricInfo.token, + website: website.url, + } + } catch (e) { + console.error(`Failed to deploy todo app ${$app.stage} stack`, e) + } + }, +}) + +function applyMigrations(uri: string) { + execSync(`pnpm exec pg-migrations apply --directory ./db/migrations`, { + env: { + ...process.env, + DATABASE_URL: uri, + }, + }) +} + +async function addDatabaseToElectric( + uri: string +): Promise<{ id: string; token: string }> { + const adminApi = process.env.ELECTRIC_ADMIN_API + + const result = await fetch(`${adminApi}/v1/databases`, { + method: `PUT`, + headers: { 'Content-Type': `application/json` }, + body: JSON.stringify({ + database_url: uri, + region: `us-east-1`, + }), + }) + + if (!result.ok) { + throw new Error( + `Could not add database to Electric (${result.status}): ${await result.text()}` + ) + } + + return await result.json() +} diff --git a/examples/linearlite/supabase/.gitignore b/examples/linearlite/supabase/.gitignore new file mode 100644 index 0000000000..a3ad88055b --- /dev/null +++ b/examples/linearlite/supabase/.gitignore @@ -0,0 +1,4 @@ +# Supabase +.branches +.temp +.env diff --git a/examples/linearlite/supabase/config.toml b/examples/linearlite/supabase/config.toml new file mode 100644 index 0000000000..63fcd1ab3c --- /dev/null +++ b/examples/linearlite/supabase/config.toml @@ -0,0 +1,275 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "linearlite" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` is always included. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. `public` is always included. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +enabled = false + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 15 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory. For example: +# sql_paths = ['./seeds/*.sql', '../project-src/seeds/*-load-testing.sql'] +sql_paths = ['./seed.sql'] + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +[storage.image_transformation] +enabled = true + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control use of MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = true +verify_enabled = true + +# Configure Multi-factor-authentication via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure Multi-factor-authentication via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +[edge_runtime] +enabled = true +# Configure one of the supported request policies: `oneshot`, `per_worker`. +# Use `oneshot` for hot reload, or `per_worker` for load testing. +policy = "oneshot" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 + +# Use these configurations to customize your Edge Function. +# [functions.MY_FUNCTION_NAME] +# enabled = true +# verify_jwt = true +# import_map = "./functions/MY_FUNCTION_NAME/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +# entrypoint = "./functions/MY_FUNCTION_NAME/index.ts" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/examples/linearlite/supabase/functions/write-server/index.ts b/examples/linearlite/supabase/functions/write-server/index.ts new file mode 100644 index 0000000000..6554c8730f --- /dev/null +++ b/examples/linearlite/supabase/functions/write-server/index.ts @@ -0,0 +1,130 @@ +// Follow this setup guide to integrate the Deno language server with your editor: +// https://deno.land/manual/getting_started/setup_your_environment +// This enables autocomplete, go to definition, etc. + +// Setup type definitions for built-in Supabase Runtime APIs +import 'jsr:@supabase/functions-js/edge-runtime.d.ts' +import { Hono } from 'jsr:@hono/hono' +import { cors } from 'jsr:@hono/hono/cors' +import postgres from 'https://deno.land/x/postgresjs/mod.js' +import { z } from 'https://deno.land/x/zod/mod.ts' + +const issueChangeSchema = z.object({ + id: z.string(), + title: z.string().nullable().optional(), + description: z.string().nullable().optional(), + priority: z.string().nullable().optional(), + status: z.string().nullable().optional(), + modified: z.string().nullable().optional(), + created: z.string().nullable().optional(), + kanbanorder: z.string().nullable().optional(), + username: z.string().nullable().optional(), + modified_columns: z.array(z.string()).nullable().optional(), + deleted: z.boolean().nullable().optional(), + new: z.boolean().nullable().optional(), +}) + +type IssueChange = z.infer + +const commentChangeSchema = z.object({ + id: z.string(), + body: z.string().nullable().optional(), + username: z.string().nullable().optional(), + issue_id: z.string().nullable().optional(), + modified: z.string().nullable().optional(), + created: z.string().nullable().optional(), + modified_columns: z.array(z.string()).nullable().optional(), + deleted: z.boolean().nullable().optional(), + new: z.boolean().nullable().optional(), +}) + +type CommentChange = z.infer + +const changeSetSchema = z.object({ + issues: z.array(issueChangeSchema), + comments: z.array(commentChangeSchema), +}) + +type ChangeSet = z.infer + +const DATABASE_URL = Deno.env.get('SUPABASE_DB_URL')! + +// Create postgres connection +const sql = postgres(DATABASE_URL) + +const app = new Hono() + +// Middleware +app.use('/write-server/*', cors()) + +// Routes +app.get('/write-server/', async (c) => { + const result = await sql` + SELECT 'ok' as status, version() as postgres_version, now() as server_time + ` + return c.json(result[0]) +}) + +app.post('/write-server/apply-changes', async (c) => { + const content = await c.req.json() + let parsedChanges: ChangeSet + try { + parsedChanges = changeSetSchema.parse(content) + // Any additional validation of the changes can be done here! + } catch (error) { + console.error(error) + return c.json({ error: 'Invalid changes' }, 400) + } + const changeResponse = await applyChanges(parsedChanges) + return c.json(changeResponse) +}) + +async function applyChanges(changes: ChangeSet): Promise<{ success: boolean }> { + const { issues, comments } = changes + + try { + await sql.begin(async (sql) => { + for (const issue of issues) { + await applyTableChange('issue', issue, sql) + } + for (const comment of comments) { + await applyTableChange('comment', comment, sql) + } + }) + return { success: true } + } catch (error) { + throw error + } +} + +async function applyTableChange( + tableName: 'issue' | 'comment', + change: IssueChange | CommentChange, + sql: postgres.TransactionSql +): Promise { + const { + id, + modified_columns: modified_columns_raw, + new: isNew, + deleted, + } = change + const modified_columns = modified_columns_raw as (keyof typeof change)[] + + if (deleted) { + await sql` + DELETE FROM ${sql(tableName)} WHERE id = ${id} + ` + } else if (isNew) { + await sql` + INSERT INTO ${sql(tableName)} ${sql(change, 'id', ...modified_columns)} + ` + } else { + await sql` + UPDATE ${sql(tableName)} + SET ${sql(change, ...modified_columns)} + WHERE id = ${id} + ` + } +} + +Deno.serve(app.fetch) diff --git a/examples/linearlite/tailwind.config.js b/examples/linearlite/tailwind.config.js new file mode 100644 index 0000000000..04d9eb03dd --- /dev/null +++ b/examples/linearlite/tailwind.config.js @@ -0,0 +1,88 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{js,jsx,ts,tsx}', './public/index.html'], + darkMode: ['class', '[data-theme="dark"]'], + theme: { + screens: { + sm: '640px', + // => @media (min-width: 640px) { ... } + + md: '768px', + // => @media (min-width: 768px) { ... } + + lg: '1024px', + // => @media (min-width: 1024px) { ... } + + xl: '1280px', + // => @media (min-width: 1280px) { ... } + + '2xl': '1536px', + // => @media (min-width: 1536px) { ... } + }, + // color: { + // // gray: colors.trueGray, + // }, + fontFamily: { + sans: [ + 'Inter\\ UI', + 'SF\\ Pro\\ Display', + '-apple-system', + 'BlinkMacSystemFont', + 'Segoe\\ UI', + 'Roboto', + 'Oxygen', + 'Ubuntu', + 'Cantarell', + 'Open\\ Sans', + 'Helvetica\\ Neue', + 'sans-serif', + ], + }, + borderWidth: { + DEFAULT: '1px', + 0: '0', + 2: '2px', + 3: '3px', + 4: '4px', + 6: '6px', + 8: '8px', + }, + extend: { + boxShadow: { + modal: 'rgb(0 0 0 / 9%) 0px 3px 12px', + 'large-modal': 'rgb(0 0 0 / 50%) 0px 16px 70px', + }, + spacing: { + 2.5: '10px', + 4.5: '18px', + 3.5: '14px', + 34: '136px', + + 70: '280px', + 140: '560px', + 100: '400px', + 175: '700px', + 53: '212px', + 90: '360px', + }, + fontSize: { + xxs: '0.5rem', + xs: '0.75rem', // 12px + sm: '0.8125rem', // 13px + md: '0.9357rem', //15px + 14: '0.875rem', + base: '1.0rem', // 16px + }, + zIndex: { + 100: 100, + }, + }, + }, + variants: { + extend: { + backgroundColor: ['checked'], + borderColor: ['checked'], + }, + }, + plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')], +} diff --git a/examples/linearlite/tsconfig.json b/examples/linearlite/tsconfig.json new file mode 100644 index 0000000000..b2078d7e10 --- /dev/null +++ b/examples/linearlite/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "types": ["vite/client", "vite-plugin-svgr/client"], + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["sst.config.ts"] +} diff --git a/examples/linearlite/vite.config.ts b/examples/linearlite/vite.config.ts new file mode 100644 index 0000000000..d77861d2b3 --- /dev/null +++ b/examples/linearlite/vite.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import svgr from 'vite-plugin-svgr' + +// https://vitejs.dev/config/ +export default defineConfig({ + optimizeDeps: { + exclude: ['@electric-sql/pglite'], + }, + worker: { + format: 'es', + }, + plugins: [ + react(), + svgr({ + svgrOptions: { + svgo: true, + plugins: [`@svgr/plugin-svgo`, `@svgr/plugin-jsx`], + svgoConfig: { + plugins: [ + `preset-default`, + `removeTitle`, + `removeDesc`, + `removeDoctype`, + `cleanupIds`, + ], + }, + }, + }), + ], +})