├── .gitignore ├── .prettierrc ├── README.md ├── apps ├── chat │ ├── README.md │ ├── index.html │ ├── package.json │ ├── public │ │ └── favicon.ico │ ├── src │ │ ├── app.tsx │ │ ├── client.tsx │ │ ├── livestore.worker.ts │ │ ├── livestore │ │ │ ├── queries.ts │ │ │ └── schema.ts │ │ ├── server.ts │ │ └── util │ │ │ └── store-id.ts │ ├── tsconfig.json │ ├── vite.config.ts │ └── wrangler.jsonc ├── linearlite │ ├── README.md │ ├── index.html │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── fonts │ │ │ ├── inter-extrabold.woff │ │ │ ├── inter-extrabold.woff2 │ │ │ ├── inter-medium.woff │ │ │ ├── inter-medium.woff2 │ │ │ ├── inter-regular.woff │ │ │ ├── inter-regular.woff2 │ │ │ ├── inter-semibold.woff │ │ │ └── inter-semibold.woff2 │ │ └── netlify.toml │ ├── src │ │ ├── app │ │ │ ├── app.tsx │ │ │ ├── contexts.ts │ │ │ ├── main.tsx │ │ │ ├── provider.tsx │ │ │ └── style.css │ │ ├── components │ │ │ ├── common │ │ │ │ ├── avatar.tsx │ │ │ │ ├── editor-menu.tsx │ │ │ │ ├── editor.tsx │ │ │ │ ├── menu-button.tsx │ │ │ │ ├── modal.tsx │ │ │ │ ├── priority-menu.tsx │ │ │ │ ├── shortcut.tsx │ │ │ │ └── status-menu.tsx │ │ │ ├── icons │ │ │ │ ├── backlog.tsx │ │ │ │ ├── canceled.tsx │ │ │ │ ├── done.tsx │ │ │ │ ├── filter.tsx │ │ │ │ ├── in-progress.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── linear-lite.tsx │ │ │ │ ├── livestore.tsx │ │ │ │ ├── new-issue.tsx │ │ │ │ ├── priority-high.tsx │ │ │ │ ├── priority-low.tsx │ │ │ │ ├── priority-medium.tsx │ │ │ │ ├── priority-none.tsx │ │ │ │ ├── priority-urgent.tsx │ │ │ │ ├── sidebar.tsx │ │ │ │ └── todo.tsx │ │ │ └── layout │ │ │ │ ├── board │ │ │ │ ├── card.tsx │ │ │ │ ├── column.tsx │ │ │ │ ├── draggable.tsx │ │ │ │ └── index.tsx │ │ │ │ ├── filters │ │ │ │ ├── filter-menu.tsx │ │ │ │ ├── header.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── priority-filter.tsx │ │ │ │ ├── sort-menu.tsx │ │ │ │ └── status-filter.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── issue │ │ │ │ ├── back-button.tsx │ │ │ │ ├── comment-input.tsx │ │ │ │ ├── comments.tsx │ │ │ │ ├── delete-button.tsx │ │ │ │ ├── description-input.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── new-issue-modal.tsx │ │ │ │ └── title-input.tsx │ │ │ │ ├── list │ │ │ │ ├── filtered-list.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── row.tsx │ │ │ │ └── virtual-row.tsx │ │ │ │ ├── search │ │ │ │ ├── index.tsx │ │ │ │ └── search-bar.tsx │ │ │ │ ├── sidebar │ │ │ │ ├── about-menu.tsx │ │ │ │ ├── about-modal.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── mobile-menu.tsx │ │ │ │ ├── new-issue-button.tsx │ │ │ │ ├── search-button.tsx │ │ │ │ └── theme-button.tsx │ │ │ │ └── toolbar │ │ │ │ ├── devtools-button.tsx │ │ │ │ ├── download-button.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── mobile-menu.tsx │ │ │ │ ├── reset-button.tsx │ │ │ │ ├── seed-input.tsx │ │ │ │ ├── share-button.tsx │ │ │ │ ├── sync-toggle.tsx │ │ │ │ ├── toolbar-button.tsx │ │ │ │ └── user-input.tsx │ │ ├── data │ │ │ ├── priority-options.ts │ │ │ ├── sorting-options.ts │ │ │ ├── status-options.ts │ │ │ └── theme-options.ts │ │ ├── hooks │ │ │ ├── useClickOutside.ts │ │ │ ├── useDebounce.ts │ │ │ └── useLockBodyScroll.ts │ │ ├── lib │ │ │ └── livestore │ │ │ │ ├── events.ts │ │ │ │ ├── queries.ts │ │ │ │ ├── schema │ │ │ │ ├── comment.ts │ │ │ │ ├── description.ts │ │ │ │ ├── filter-state.ts │ │ │ │ ├── frontend-state.ts │ │ │ │ ├── index.ts │ │ │ │ ├── issue.ts │ │ │ │ └── scroll-state.ts │ │ │ │ ├── seed.ts │ │ │ │ ├── utils.tsx │ │ │ │ └── worker.ts │ │ ├── server.ts │ │ ├── types │ │ │ ├── comment.ts │ │ │ ├── description.ts │ │ │ ├── issue.ts │ │ │ ├── priority.ts │ │ │ └── status.ts │ │ └── utils │ │ │ ├── format-date.ts │ │ │ ├── get-acronym.ts │ │ │ └── get-issue-tag.ts │ ├── tailwind.config.cjs │ ├── tsconfig.json │ ├── vite.config.ts │ └── wrangler.jsonc ├── react-router │ ├── .react-router │ │ └── types │ │ │ ├── +future.ts │ │ │ ├── +routes.ts │ │ │ ├── +server-build.d.ts │ │ │ └── app │ │ │ ├── +types │ │ │ └── root.ts │ │ │ └── routes │ │ │ └── +types │ │ │ └── home.ts │ ├── README.md │ ├── app │ │ ├── app.css │ │ ├── counter.tsx │ │ ├── entry.server.tsx │ │ ├── livestore │ │ │ ├── livestore.worker.ts │ │ │ └── schema.ts │ │ ├── root.tsx │ │ ├── routes.ts │ │ ├── routes │ │ │ └── home.tsx │ │ └── server.ts │ ├── package.json │ ├── public │ │ └── favicon.ico │ ├── react-router.config.ts │ ├── tsconfig.json │ ├── vite.config.ts │ └── wrangler.jsonc └── todomvc │ ├── README.md │ ├── index.html │ ├── package.json │ ├── public │ └── favicon.ico │ ├── src │ ├── app.tsx │ ├── client.tsx │ ├── components │ │ ├── Footer.tsx │ │ ├── Header.tsx │ │ └── MainSection.tsx │ ├── livestore.worker.ts │ ├── livestore │ │ ├── queries.ts │ │ └── schema.ts │ ├── server.ts │ └── util │ │ └── store-id.ts │ ├── tsconfig.json │ ├── vite.config.ts │ └── wrangler.jsonc ├── package-lock.json ├── package.json ├── patches └── @livestore+sync-cf+0.3.0.patch └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # vitepress build output 108 | **/.vitepress/dist 109 | 110 | # vitepress cache directory 111 | **/.vitepress/cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | 138 | build 139 | 140 | 141 | .wrangler 142 | .dev.vars 143 | 144 | .DS_Store -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none" 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## livestore ⨉ cloudflare 2 | 3 | experiments with livestore on cloudflare workers 4 | 5 | - livestore's cf sync has been patched so it uses the DO's storage instead of D1 6 | -------------------------------------------------------------------------------- /apps/chat/README.md: -------------------------------------------------------------------------------- 1 | ## chat (only stubs, not implemented yet) 2 | 3 | ### dev 4 | 5 | `npm i && npm start` 6 | 7 | ### deploy 8 | 9 | `npm i && npm run deploy` 10 | 11 | (don't forget to change the value of `VITE_LIVESTORE_SYNC_URL` in `package.json`) 12 | -------------------------------------------------------------------------------- /apps/chat/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TodoMVC 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /apps/chat/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "start": "VITE_LIVESTORE_SYNC_URL=http://localhost:5173 vite dev", 6 | "deploy": "VITE_LIVESTORE_SYNC_URL=http://livestore-todomvc.threepointone.workers.dev vite build && wrangler deploy", 7 | "clean": "rm -rf node_modules/.vite .wrangler" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "type": "module" 13 | } 14 | -------------------------------------------------------------------------------- /apps/chat/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threepointone/livestore-cf-examples/ebdae73b8481165ca0ad2310df2c07373b28b501/apps/chat/public/favicon.ico -------------------------------------------------------------------------------- /apps/chat/src/app.tsx: -------------------------------------------------------------------------------- 1 | import { makePersistedAdapter } from "@livestore/adapter-web"; 2 | import LiveStoreSharedWorker from "@livestore/adapter-web/shared-worker?sharedworker"; 3 | import { LiveStoreProvider } from "@livestore/react"; 4 | import { FPSMeter } from "@overengineering/fps-meter"; 5 | import type React from "react"; 6 | import { unstable_batchedUpdates as batchUpdates } from "react-dom"; 7 | 8 | import LiveStoreWorker from "./livestore.worker?worker"; 9 | import { schema } from "./livestore/schema.js"; 10 | import { getStoreId } from "./util/store-id.js"; 11 | 12 | const AppBody: React.FC = () => ( 13 |
14 |

Chat

15 |
16 | ); 17 | 18 | const storeId = getStoreId(); 19 | 20 | const adapter = makePersistedAdapter({ 21 | storage: { type: "opfs" }, 22 | worker: LiveStoreWorker, 23 | sharedWorker: LiveStoreSharedWorker 24 | }); 25 | 26 | export const App: React.FC = () => ( 27 |
Loading LiveStore ({_.stage})...
} 31 | batchUpdates={batchUpdates} 32 | storeId={storeId} 33 | syncPayload={{ authToken: "insecure-token-change-me" }} 34 | > 35 |
36 | 37 |
38 | 39 |
40 | ); 41 | -------------------------------------------------------------------------------- /apps/chat/src/client.tsx: -------------------------------------------------------------------------------- 1 | import "todomvc-app-css/index.css"; 2 | 3 | import { createRoot } from "react-dom/client"; 4 | 5 | import { App } from "./app"; 6 | 7 | createRoot(document.getElementById("app")!).render(); 8 | 9 | // ReactDOM.createRoot(document.getElementById('react-app')!).render( 10 | // 11 | // 12 | // , 13 | // ) 14 | -------------------------------------------------------------------------------- /apps/chat/src/livestore.worker.ts: -------------------------------------------------------------------------------- 1 | import { schema } from "./livestore/schema.js"; 2 | import { makeWorker } from "@livestore/adapter-web/worker"; 3 | import { makeCfSync } from "@livestore/sync-cf"; 4 | 5 | makeWorker({ 6 | schema, 7 | sync: { 8 | backend: makeCfSync({ 9 | url: import.meta.env.VITE_LIVESTORE_SYNC_URL as string 10 | }), 11 | initialSyncOptions: { _tag: "Blocking", timeout: 5000 } 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /apps/chat/src/livestore/queries.ts: -------------------------------------------------------------------------------- 1 | import { queryDb } from "@livestore/livestore"; 2 | 3 | import { tables } from "./schema.js"; 4 | 5 | export const uiState$ = queryDb(tables.uiState.get(), { label: "uiState" }); 6 | -------------------------------------------------------------------------------- /apps/chat/src/livestore/schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Events, 3 | makeSchema, 4 | Schema, 5 | SessionIdSymbol, 6 | State 7 | } from "@livestore/livestore"; 8 | 9 | // You can model your state as SQLite tables (https://docs.livestore.dev/reference/state/sqlite-schema) 10 | export const tables = { 11 | messages: State.SQLite.table({ 12 | name: "messages", 13 | columns: { 14 | id: State.SQLite.text({ primaryKey: true }), 15 | content: State.SQLite.text({ default: "" }), 16 | role: State.SQLite.text({ default: "user" }), // 'user' or 'assistant' 17 | timestamp: State.SQLite.integer({ 18 | default: new Date(), 19 | schema: Schema.DateFromNumber 20 | }), 21 | deletedAt: State.SQLite.integer({ 22 | nullable: true, 23 | schema: Schema.DateFromNumber 24 | }) 25 | } 26 | }), 27 | // Client documents can be used for local-only state (e.g. form inputs) 28 | uiState: State.SQLite.clientDocument({ 29 | name: "uiState", 30 | schema: Schema.Struct({ 31 | inputText: Schema.String, 32 | isTyping: Schema.Boolean, 33 | selectedModel: Schema.String 34 | }), 35 | default: { 36 | id: SessionIdSymbol, 37 | value: { 38 | inputText: "", 39 | isTyping: false, 40 | selectedModel: "gpt-3.5-turbo" 41 | } 42 | } 43 | }) 44 | }; 45 | 46 | // Events describe data changes (https://docs.livestore.dev/reference/events) 47 | export const events = { 48 | messageCreated: Events.synced({ 49 | name: "v1.MessageCreated", 50 | schema: Schema.Struct({ 51 | id: Schema.String, 52 | content: Schema.String, 53 | role: Schema.String, 54 | timestamp: Schema.Date 55 | }) 56 | }), 57 | messageDeleted: Events.synced({ 58 | name: "v1.MessageDeleted", 59 | schema: Schema.Struct({ 60 | id: Schema.String, 61 | deletedAt: Schema.Date 62 | }) 63 | }), 64 | conversationCleared: Events.synced({ 65 | name: "v1.ConversationCleared", 66 | schema: Schema.Struct({ 67 | deletedAt: Schema.Date 68 | }) 69 | }), 70 | uiStateSet: tables.uiState.set 71 | }; 72 | 73 | // Materializers are used to map events to state (https://docs.livestore.dev/reference/state/materializers) 74 | const materializers = State.SQLite.materializers(events, { 75 | "v1.MessageCreated": ({ id, content, role, timestamp }) => 76 | tables.messages.insert({ id, content, role, timestamp }), 77 | "v1.MessageDeleted": ({ id, deletedAt }) => 78 | tables.messages.update({ deletedAt }).where({ id }), 79 | "v1.ConversationCleared": ({ deletedAt }) => 80 | tables.messages.update({ deletedAt }).where({ deletedAt: null }) 81 | }); 82 | 83 | const state = State.SQLite.makeState({ tables, materializers }); 84 | 85 | export const schema = makeSchema({ events, state }); 86 | -------------------------------------------------------------------------------- /apps/chat/src/server.ts: -------------------------------------------------------------------------------- 1 | import { makeDurableObject, makeWorker } from "@livestore/sync-cf/cf-worker"; 2 | 3 | export class WebSocketServer extends makeDurableObject({ 4 | onPush: async (message) => { 5 | console.log("onPush", message.batch); 6 | }, 7 | onPull: async (message) => { 8 | console.log("onPull", message); 9 | } 10 | }) {} 11 | 12 | export default makeWorker({ 13 | validatePayload: (payload: any) => { 14 | if (payload?.authToken !== "insecure-token-change-me") { 15 | throw new Error("Invalid auth token"); 16 | } 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /apps/chat/src/util/store-id.ts: -------------------------------------------------------------------------------- 1 | export const getStoreId = () => { 2 | if (typeof window === "undefined") return "unused"; 3 | 4 | const searchParams = new URLSearchParams(window.location.search); 5 | const storeId = searchParams.get("storeId"); 6 | if (storeId !== null) return storeId; 7 | 8 | const newAppId = crypto.randomUUID(); 9 | searchParams.set("storeId", newAppId); 10 | 11 | window.location.search = searchParams.toString(); 12 | }; 13 | -------------------------------------------------------------------------------- /apps/chat/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /apps/chat/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import { cloudflare } from "@cloudflare/vite-plugin"; 4 | import { livestoreDevtoolsPlugin } from "@livestore/devtools-vite"; 5 | import devtoolsJson from "vite-plugin-devtools-json"; 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | devtoolsJson(), 10 | react(), 11 | cloudflare(), 12 | livestoreDevtoolsPlugin({ schemaPath: "./src/livestore/schema.ts" }) 13 | ] 14 | }); 15 | -------------------------------------------------------------------------------- /apps/chat/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "name": "livestore-chat", 3 | "main": "./src/server.ts", 4 | "compatibility_date": "2025-05-08", 5 | "compatibility_flags": ["nodejs_compat"], 6 | "durable_objects": { 7 | "bindings": [ 8 | { 9 | "name": "WEBSOCKET_SERVER", 10 | "class_name": "WebSocketServer" 11 | } 12 | ] 13 | }, 14 | "migrations": [ 15 | { 16 | "tag": "v1", 17 | "new_sqlite_classes": ["WebSocketServer"] 18 | } 19 | ], 20 | // "d1_databases": [ 21 | // { 22 | // "binding": "DB", 23 | // "database_name": "livestore-todomvc", 24 | // "database_id": "1c9b5dae-f1fa-49d8-83fa-7bd5b39c4121" 25 | // // "database_id": "${LIVESTORE_CF_SYNC_DATABASE_ID}" 26 | // } 27 | // ], 28 | "vars": { 29 | // should be set via CF dashboard (as secret) 30 | // "ADMIN_SECRET": "..." 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/linearlite/README.md: -------------------------------------------------------------------------------- 1 | ## todomvc 2 | 3 | ### dev 4 | 5 | `npm i && npm start` 6 | 7 | ### deploy 8 | 9 | `npm i && npm run deploy` 10 | 11 | (don't forget to change the value of `VITE_LIVESTORE_SYNC_URL` in `package.json`) 12 | 13 | - sync's been disabled since it seems buggy atm 14 | -------------------------------------------------------------------------------- /apps/linearlite/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | LinearLite 8 | 9 | 10 | 14 | 15 | 16 |
17 |
18 | 19 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /apps/linearlite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "livestore-linearlite", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "dependencies": { 6 | "@headlessui/react": "2.2.4", 7 | "@heroicons/react": "2.2.0", 8 | "@tailwindcss/forms": "0.5.10", 9 | "@tiptap/core": "2.12.0", 10 | "@tiptap/extension-placeholder": "2.12.0", 11 | "@tiptap/extension-table": "2.12.0", 12 | "@tiptap/extension-table-cell": "2.12.0", 13 | "@tiptap/extension-table-header": "2.12.0", 14 | "@tiptap/extension-table-row": "2.12.0", 15 | "@tiptap/pm": "2.12.0", 16 | "@tiptap/react": "2.12.0", 17 | "@tiptap/starter-kit": "2.12.0", 18 | "animate.css": "4.1.1", 19 | "classnames": "2.5.1", 20 | "fractional-indexing": "3.2.0", 21 | "react-aria": "3.40.0", 22 | "react-aria-components": "1.9.0", 23 | "react-beautiful-dnd": "13.1.1", 24 | "react-icons": "5.5.0", 25 | "react-markdown": "10.1.0", 26 | "react-router-dom": "7.6.1", 27 | "react-virtualized-auto-sizer": "1.0.26", 28 | "react-window": "1.8.11", 29 | "tiptap-markdown": "0.8.10" 30 | }, 31 | "devDependencies": { 32 | "@svgr/plugin-jsx": "^8.1.0", 33 | "@svgr/plugin-svgo": "^8.1.0", 34 | "@tailwindcss/typography": "^0.5.16", 35 | "@tailwindcss/vite": "^4.1.7", 36 | "@types/react-beautiful-dnd": "^13.1.8", 37 | "@types/react-router-dom": "^5.3.3", 38 | "@types/react-window": "^1.8.8", 39 | "prompt": "^1.3.0", 40 | "tailwindcss": "^4.1.7", 41 | "typescript": "^5.8.3", 42 | "vite-plugin-svgr": "^4.3.0" 43 | }, 44 | "engines": { 45 | "node": ">=23.0.0" 46 | }, 47 | "scripts": { 48 | "start": "VITE_LIVESTORE_SYNC_URL=http://localhost:5173 vite dev", 49 | "deploy": "VITE_LIVESTORE_SYNC_URL=http://livestore-linearlite.threepointone.workers.dev vite build && wrangler deploy", 50 | "clean": "rm -rf node_modules/.vite .wrangler" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /apps/linearlite/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threepointone/livestore-cf-examples/ebdae73b8481165ca0ad2310df2c07373b28b501/apps/linearlite/public/favicon.ico -------------------------------------------------------------------------------- /apps/linearlite/public/fonts/inter-extrabold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threepointone/livestore-cf-examples/ebdae73b8481165ca0ad2310df2c07373b28b501/apps/linearlite/public/fonts/inter-extrabold.woff -------------------------------------------------------------------------------- /apps/linearlite/public/fonts/inter-extrabold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threepointone/livestore-cf-examples/ebdae73b8481165ca0ad2310df2c07373b28b501/apps/linearlite/public/fonts/inter-extrabold.woff2 -------------------------------------------------------------------------------- /apps/linearlite/public/fonts/inter-medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threepointone/livestore-cf-examples/ebdae73b8481165ca0ad2310df2c07373b28b501/apps/linearlite/public/fonts/inter-medium.woff -------------------------------------------------------------------------------- /apps/linearlite/public/fonts/inter-medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threepointone/livestore-cf-examples/ebdae73b8481165ca0ad2310df2c07373b28b501/apps/linearlite/public/fonts/inter-medium.woff2 -------------------------------------------------------------------------------- /apps/linearlite/public/fonts/inter-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threepointone/livestore-cf-examples/ebdae73b8481165ca0ad2310df2c07373b28b501/apps/linearlite/public/fonts/inter-regular.woff -------------------------------------------------------------------------------- /apps/linearlite/public/fonts/inter-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threepointone/livestore-cf-examples/ebdae73b8481165ca0ad2310df2c07373b28b501/apps/linearlite/public/fonts/inter-regular.woff2 -------------------------------------------------------------------------------- /apps/linearlite/public/fonts/inter-semibold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threepointone/livestore-cf-examples/ebdae73b8481165ca0ad2310df2c07373b28b501/apps/linearlite/public/fonts/inter-semibold.woff -------------------------------------------------------------------------------- /apps/linearlite/public/fonts/inter-semibold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threepointone/livestore-cf-examples/ebdae73b8481165ca0ad2310df2c07373b28b501/apps/linearlite/public/fonts/inter-semibold.woff2 -------------------------------------------------------------------------------- /apps/linearlite/public/netlify.toml: -------------------------------------------------------------------------------- 1 | 2 | [[redirects]] 3 | from = "/*" 4 | to = "/index.html" 5 | status = 200 6 | -------------------------------------------------------------------------------- /apps/linearlite/src/app/app.tsx: -------------------------------------------------------------------------------- 1 | import { Provider } from "@/app/provider"; 2 | import { Layout } from "@/components/layout"; 3 | import { Board } from "@/components/layout/board"; 4 | import { Issue } from "@/components/layout/issue"; 5 | import { NewIssueModal } from "@/components/layout/issue/new-issue-modal"; 6 | import { List } from "@/components/layout/list"; 7 | import { Search } from "@/components/layout/search"; 8 | import { Sidebar } from "@/components/layout/sidebar"; 9 | import "animate.css/animate.min.css"; 10 | import { BrowserRouter, Route, Routes } from "react-router-dom"; 11 | 12 | export const App = () => { 13 | const router = ( 14 | 15 | } /> 16 | } /> 17 | } /> 18 | } /> 19 | 20 | ); 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 |
28 |
29 | {router} 30 |
31 |
32 |
33 | 34 |
35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /apps/linearlite/src/app/contexts.ts: -------------------------------------------------------------------------------- 1 | import { Status } from "@/types/status"; 2 | import React from "react"; 3 | 4 | interface MenuContextInterface { 5 | showMenu: boolean; 6 | setShowMenu: (show: boolean) => void; 7 | } 8 | interface NewIssueModalContextInterface { 9 | newIssueModalStatus: Status | boolean; 10 | setNewIssueModalStatus: (status: Status | false) => void; 11 | } 12 | 13 | export const MenuContext = React.createContext( 14 | null as MenuContextInterface | null 15 | ); 16 | export const NewIssueModalContext = React.createContext( 17 | null as NewIssueModalContextInterface | null 18 | ); 19 | -------------------------------------------------------------------------------- /apps/linearlite/src/app/main.tsx: -------------------------------------------------------------------------------- 1 | import { App } from "@/app/app"; 2 | import "@/app/style.css"; 3 | import React from "react"; 4 | import { createRoot } from "react-dom/client"; 5 | 6 | const root = createRoot(document.getElementById("root")!); 7 | root.render(); 8 | -------------------------------------------------------------------------------- /apps/linearlite/src/app/provider.tsx: -------------------------------------------------------------------------------- 1 | import { MenuContext, NewIssueModalContext } from "@/app/contexts"; 2 | import { schema } from "@/lib/livestore/schema"; 3 | import { renderBootStatus } from "@/lib/livestore/utils"; 4 | import LiveStoreWorker from "@/lib/livestore/worker?worker"; 5 | import { Status } from "@/types/status"; 6 | import { LiveStoreProvider } from "@livestore/react"; 7 | import { makePersistedAdapter } from "@livestore/adapter-web"; 8 | import LiveStoreSharedWorker from "@livestore/adapter-web/shared-worker?sharedworker"; 9 | import React from "react"; 10 | import { unstable_batchedUpdates as batchUpdates } from "react-dom"; 11 | import { useNavigate } from "react-router-dom"; 12 | 13 | const resetPersistence = 14 | import.meta.env.DEV && 15 | new URLSearchParams(window.location.search).get("reset") !== null; 16 | 17 | if (resetPersistence) { 18 | const searchParams = new URLSearchParams(window.location.search); 19 | searchParams.delete("reset"); 20 | window.history.replaceState( 21 | null, 22 | "", 23 | `${window.location.pathname}?${searchParams.toString()}` 24 | ); 25 | } 26 | 27 | const storeId = "test-store-id"; 28 | 29 | const adapter = makePersistedAdapter({ 30 | worker: LiveStoreWorker, 31 | sharedWorker: LiveStoreSharedWorker, 32 | storage: { type: "opfs" }, 33 | // NOTE this should only be used for convenience when developing (i.e. via `?reset` in the URL) and is disabled in production 34 | resetPersistence 35 | }); 36 | 37 | export const Provider = ({ children }: { children: React.ReactNode }) => { 38 | const navigate = useNavigate(); 39 | const [showMenu, setShowMenu] = React.useState(false); 40 | const [newIssueModalStatus, setNewIssueModalStatus] = React.useState< 41 | Status | false 42 | >(false); 43 | 44 | React.useEffect(() => { 45 | const handleKeyDown = (e: KeyboardEvent) => { 46 | const element = e.target as HTMLElement; 47 | if (element.classList.contains("input")) return; 48 | if (e.key === "c") { 49 | if (!element.classList.contains("input")) { 50 | setNewIssueModalStatus(0); 51 | e.preventDefault(); 52 | } 53 | } 54 | if (e.key === "/" && e.shiftKey) { 55 | navigate("/search"); 56 | e.preventDefault(); 57 | } 58 | }; 59 | window.addEventListener("keydown", handleKeyDown); 60 | return () => window.removeEventListener("keydown", handleKeyDown); 61 | }, [navigate]); 62 | 63 | return ( 64 | 72 | 73 | 76 | {children} 77 | 78 | 79 | 80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /apps/linearlite/src/app/style.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @config "../../tailwind.config.cjs"; 4 | 5 | body { 6 | @apply bg-white text-xs text-neutral-600 dark:bg-neutral-900 dark:text-neutral-200 antialiased; 7 | } 8 | 9 | @font-face { 10 | font-family: "Inter UI"; 11 | font-style: normal; 12 | font-weight: 400; 13 | font-display: swap; 14 | src: 15 | url("/fonts/inter-regular.woff2") format("woff2"), 16 | url("/fonts/inter-regular.woff") format("woff"); 17 | } 18 | 19 | @font-face { 20 | font-family: "Inter UI"; 21 | font-style: normal; 22 | font-weight: 500; 23 | font-display: swap; 24 | src: 25 | url("/fonts/inter-medium.woff2") format("woff2"), 26 | url("/fonts/inter-medium.woff") format("woff"); 27 | } 28 | 29 | @font-face { 30 | font-family: "Inter UI"; 31 | font-style: normal; 32 | font-weight: 600; 33 | font-display: swap; 34 | src: 35 | url("/fonts/inter-semibold.woff2") format("woff2"), 36 | url("/fonts/inter-semibold.woff") format("woff"); 37 | } 38 | 39 | @font-face { 40 | font-family: "Inter UI"; 41 | font-style: normal; 42 | font-weight: 800; 43 | font-display: swap; 44 | src: 45 | url("/fonts/inter-extrabold.woff2") format("woff2"), 46 | url("/fonts/inter-extrabold.woff") format("woff"); 47 | } 48 | 49 | .modal { 50 | max-width: calc(100vw - 32px); 51 | max-height: calc(100vh - 32px); 52 | } 53 | 54 | #root, 55 | body, 56 | html { 57 | height: 100%; 58 | } 59 | 60 | .tiptap p.is-editor-empty:first-child::before { 61 | @apply text-neutral-400 dark:text-neutral-500; 62 | content: attr(data-placeholder); 63 | float: left; 64 | height: 0; 65 | pointer-events: none; 66 | } 67 | 68 | input::-webkit-outer-spin-button, 69 | input::-webkit-inner-spin-button { 70 | -webkit-appearance: none; 71 | margin: 0; 72 | } 73 | 74 | /* Firefox */ 75 | input[type="number"] { 76 | -moz-appearance: textfield; 77 | } 78 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/common/avatar.tsx: -------------------------------------------------------------------------------- 1 | import { getAcronym } from "@/utils/get-acronym"; 2 | import React from "react"; 3 | 4 | export const Avatar = ({ name }: { name?: string }) => { 5 | if (!name) name = "Me"; 6 | return ( 7 |
8 | {getAcronym(name)} 9 |
10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/common/editor-menu.tsx: -------------------------------------------------------------------------------- 1 | import { ItalicIcon } from "@heroicons/react/16/solid"; 2 | import { 3 | CodeBracketIcon, 4 | ListBulletIcon, 5 | NumberedListIcon, 6 | StrikethroughIcon 7 | } from "@heroicons/react/20/solid"; 8 | import { CodeBracketSquareIcon } from "@heroicons/react/24/outline"; 9 | import { BoldIcon } from "@heroicons/react/24/solid"; 10 | import type { Editor as TipTapEditor } from "@tiptap/react"; 11 | import React from "react"; 12 | import { Button } from "react-aria-components"; 13 | 14 | export interface EditorMenuProps { 15 | editor: TipTapEditor; 16 | } 17 | 18 | const EditorMenu = ({ editor }: EditorMenuProps) => { 19 | return ( 20 |
21 |
22 | 29 | 36 | 43 | 50 |
51 |
52 | 59 | 66 | 73 |
74 |
75 | ); 76 | }; 77 | 78 | export default EditorMenu; 79 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/common/editor.tsx: -------------------------------------------------------------------------------- 1 | import EditorMenu from "@/components/common/editor-menu"; 2 | import Placeholder from "@tiptap/extension-placeholder"; 3 | import Table from "@tiptap/extension-table"; 4 | import TableCell from "@tiptap/extension-table-cell"; 5 | import TableHeader from "@tiptap/extension-table-header"; 6 | import TableRow from "@tiptap/extension-table-row"; 7 | import { 8 | BubbleMenu, 9 | EditorContent, 10 | useEditor, 11 | type Extensions 12 | } from "@tiptap/react"; 13 | import StarterKit from "@tiptap/starter-kit"; 14 | import React, { useEffect, useRef } from "react"; 15 | import { Markdown } from "tiptap-markdown"; 16 | 17 | const Editor = ({ 18 | value, 19 | onBlur, 20 | onChange, 21 | className = "", 22 | placeholder 23 | }: { 24 | value: string; 25 | onBlur?: (value: string) => void; 26 | onChange?: (value: string) => void; 27 | className?: string; 28 | placeholder?: string; 29 | }) => { 30 | const markdownValue = useRef(null); 31 | const extensions: Extensions = [ 32 | StarterKit, 33 | Markdown, 34 | Table, 35 | TableRow, 36 | TableHeader, 37 | TableCell 38 | ]; 39 | const editor = useEditor({ 40 | extensions, 41 | editorProps: { 42 | attributes: { 43 | class: `input prose text-neutral-600 dark:text-neutral-200 prose-sm prose-strong:text-neutral-600 dark:prose-strong:text-neutral-200 prose-p:my-2 prose-ol:my-2 prose-ul:my-2 prose-pre:my-2 w-full max-w-xl font-normal focus:outline-none appearance-none editor ${className}` 44 | } 45 | }, 46 | content: value || undefined, 47 | onBlur: onBlur 48 | ? ({ editor }) => { 49 | markdownValue.current = editor.storage.markdown.getMarkdown(); 50 | onBlur(markdownValue.current || ""); 51 | } 52 | : undefined, 53 | onUpdate: onChange 54 | ? ({ editor }) => { 55 | markdownValue.current = editor.storage.markdown.getMarkdown(); 56 | onChange(markdownValue.current || ""); 57 | } 58 | : undefined 59 | }); 60 | 61 | if (placeholder) extensions.push(Placeholder.configure({ placeholder })); 62 | 63 | useEffect(() => { 64 | if (editor && markdownValue.current !== value) 65 | editor.commands.setContent(value); 66 | }, [value, editor]); 67 | 68 | return ( 69 | <> 70 | 71 | {editor && ( 72 | 73 | 74 | 75 | )} 76 | 77 | ); 78 | }; 79 | 80 | export default Editor; 81 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/common/menu-button.tsx: -------------------------------------------------------------------------------- 1 | import { MenuContext } from "@/app/contexts"; 2 | import { Icon } from "@/components/icons"; 3 | import React, { useContext } from "react"; 4 | import { Button } from "react-aria-components"; 5 | 6 | export const MenuButton = ({ className }: { className?: string }) => { 7 | const { setShowMenu } = useContext(MenuContext)!; 8 | 9 | return ( 10 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/common/modal.tsx: -------------------------------------------------------------------------------- 1 | import { XMarkIcon } from "@heroicons/react/20/solid"; 2 | import React from "react"; 3 | import { 4 | Button, 5 | Heading, 6 | ModalOverlay, 7 | Modal as ReactAriaModal 8 | } from "react-aria-components"; 9 | 10 | export const Modal = ({ 11 | show, 12 | setShow, 13 | title, 14 | children 15 | }: { 16 | show: boolean; 17 | setShow: (show: boolean) => void; 18 | title?: string; 19 | children: React.ReactNode; 20 | }) => { 21 | React.useEffect(() => { 22 | const handleKeyDown = (e: KeyboardEvent) => { 23 | if (e.key === "Escape") setShow(false); 24 | }; 25 | window.addEventListener("keydown", handleKeyDown); 26 | return () => window.removeEventListener("keydown", handleKeyDown); 27 | }, [setShow]); 28 | 29 | return ( 30 | 36 | 37 | {title && ( 38 |
39 | 40 | {title} 41 | 42 |
43 | )} 44 | {children} 45 | 52 |
53 |
54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/common/priority-menu.tsx: -------------------------------------------------------------------------------- 1 | import { Shortcut } from "@/components/common/shortcut"; 2 | import { Icon, IconName } from "@/components/icons"; 3 | import { priorityOptions } from "@/data/priority-options"; 4 | import { Priority } from "@/types/priority"; 5 | import { CheckIcon } from "@heroicons/react/16/solid"; 6 | import React from "react"; 7 | import { useKeyboard } from "react-aria"; 8 | import { 9 | Button, 10 | Menu, 11 | MenuItem, 12 | MenuTrigger, 13 | Popover 14 | } from "react-aria-components"; 15 | 16 | export const PriorityMenu = ({ 17 | priority, 18 | onPriorityChange, 19 | showLabel = false 20 | }: { 21 | priority: Priority; 22 | onPriorityChange: (priority: Priority) => void; 23 | showLabel?: boolean; 24 | }) => { 25 | const [isOpen, setIsOpen] = React.useState(false); 26 | 27 | const { keyboardProps } = useKeyboard({ 28 | onKeyDown: (e) => { 29 | if (e.key === "Escape") { 30 | setIsOpen(false); 31 | return; 32 | } 33 | priorityOptions.forEach(({ shortcut }, priorityOption) => { 34 | if (e.key === shortcut) { 35 | onPriorityChange(priorityOption as Priority); 36 | setIsOpen(false); 37 | return; 38 | } 39 | }); 40 | } 41 | }); 42 | 43 | return ( 44 | 45 | 55 | 59 | 60 | {priorityOptions.map( 61 | ({ name, icon, style, shortcut }, priorityOption) => ( 62 | onPriorityChange(priorityOption as Priority)} 65 | className="p-2 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-700 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-700 cursor-pointer flex items-center gap-2" 66 | > 67 | 68 | {name} 69 | {priorityOption === priority && ( 70 | 71 | )} 72 | 73 | 74 | ) 75 | )} 76 | 77 | 78 | 79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/common/shortcut.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const Shortcut = ({ 4 | keys, 5 | className 6 | }: { 7 | keys: string[]; 8 | className?: string; 9 | }) => { 10 | return ( 11 |
12 | {keys.map((key) => ( 13 |
17 | {key} 18 |
19 | ))} 20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/common/status-menu.tsx: -------------------------------------------------------------------------------- 1 | import { Shortcut } from "@/components/common/shortcut"; 2 | import { Icon, IconName } from "@/components/icons"; 3 | import { statusOptions } from "@/data/status-options"; 4 | import { Status } from "@/types/status"; 5 | import { CheckIcon } from "@heroicons/react/16/solid"; 6 | import React from "react"; 7 | import { useKeyboard } from "react-aria"; 8 | import { 9 | Button, 10 | Menu, 11 | MenuItem, 12 | MenuTrigger, 13 | Popover 14 | } from "react-aria-components"; 15 | 16 | export const StatusMenu = ({ 17 | status, 18 | onStatusChange, 19 | showLabel = false 20 | }: { 21 | status: Status; 22 | onStatusChange: (status: Status) => void; 23 | showLabel?: boolean; 24 | }) => { 25 | const [isOpen, setIsOpen] = React.useState(false); 26 | 27 | const { keyboardProps } = useKeyboard({ 28 | onKeyDown: (e) => { 29 | if (e.key === "Escape") { 30 | setIsOpen(false); 31 | return; 32 | } 33 | statusOptions.forEach(({ shortcut }, statusOption) => { 34 | if (e.key === shortcut) { 35 | onStatusChange(statusOption as Status); 36 | setIsOpen(false); 37 | return; 38 | } 39 | }); 40 | } 41 | }); 42 | 43 | return ( 44 | 45 | 55 | 59 | 60 | {statusOptions.map( 61 | ({ name, icon, style, shortcut }, statusOption) => ( 62 | onStatusChange(statusOption as Status)} 65 | className="p-2 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-700 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-700 cursor-pointer flex items-center gap-2" 66 | > 67 | 68 | {name} 69 | {statusOption === status && ( 70 | 71 | )} 72 | 73 | 74 | ) 75 | )} 76 | 77 | 78 | 79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/icons/backlog.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const BacklogIcon = ({ className }: { className?: string }) => { 4 | return ( 5 | 11 | 20 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/icons/canceled.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const CanceledIcon = ({ className }: { className?: string }) => { 4 | return ( 5 | 11 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/icons/done.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const DoneIcon = ({ className }: { className?: string }) => { 4 | return ( 5 | 11 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/icons/filter.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const FilterIcon = ({ className }: { className?: string }) => { 4 | return ( 5 | 11 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/icons/in-progress.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const InProgressIcon = ({ className }: { className?: string }) => { 4 | return ( 5 | 11 | 20 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/icons/index.tsx: -------------------------------------------------------------------------------- 1 | import { BacklogIcon } from "@/components/icons/backlog"; 2 | import { CanceledIcon } from "@/components/icons/canceled"; 3 | import { DoneIcon } from "@/components/icons/done"; 4 | import { FilterIcon } from "@/components/icons/filter"; 5 | import { InProgressIcon } from "@/components/icons/in-progress"; 6 | import { LinearLiteIcon } from "@/components/icons/linear-lite"; 7 | import { LivestoreIcon } from "@/components/icons/livestore"; 8 | import { NewIssueIcon } from "@/components/icons/new-issue"; 9 | import { PriorityHighIcon } from "@/components/icons/priority-high"; 10 | import { PriorityLowIcon } from "@/components/icons/priority-low"; 11 | import { PriorityMediumIcon } from "@/components/icons/priority-medium"; 12 | import { PriorityNoneIcon } from "@/components/icons/priority-none"; 13 | import { PriorityUrgentIcon } from "@/components/icons/priority-urgent"; 14 | import { SidebarIcon } from "@/components/icons/sidebar"; 15 | import { TodoIcon } from "@/components/icons/todo"; 16 | import React from "react"; 17 | 18 | const icons = { 19 | backlog: BacklogIcon, 20 | canceled: CanceledIcon, 21 | done: DoneIcon, 22 | filter: FilterIcon, 23 | "in-progress": InProgressIcon, 24 | linearlite: LinearLiteIcon, 25 | livestore: LivestoreIcon, 26 | "new-issue": NewIssueIcon, 27 | "priority-none": PriorityNoneIcon, 28 | "priority-low": PriorityLowIcon, 29 | "priority-medium": PriorityMediumIcon, 30 | "priority-high": PriorityHighIcon, 31 | "priority-urgent": PriorityUrgentIcon, 32 | sidebar: SidebarIcon, 33 | todo: TodoIcon 34 | }; 35 | 36 | export type IconName = keyof typeof icons; 37 | 38 | export const Icon = ({ 39 | name, 40 | className 41 | }: { 42 | name: IconName; 43 | className?: string; 44 | }) => { 45 | const Component = icons[name]; 46 | return ; 47 | }; 48 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/icons/linear-lite.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const LinearLiteIcon = ({ className }: { className?: string }) => { 4 | return ( 5 | 11 | 12 | 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/icons/livestore.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const LivestoreIcon = ({ className }: { className?: string }) => { 4 | return ( 5 | 11 | 16 | 21 | 26 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/icons/new-issue.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const NewIssueIcon = ({ className }: { className?: string }) => { 4 | return ( 5 | 11 | 16 | 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/icons/priority-high.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const PriorityHighIcon = ({ className }: { className?: string }) => { 4 | return ( 5 | 11 | 12 | 13 | 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/icons/priority-low.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const PriorityLowIcon = ({ className }: { className?: string }) => { 4 | return ( 5 | 11 | 12 | 13 | 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/icons/priority-medium.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const PriorityMediumIcon = ({ className }: { className?: string }) => { 4 | return ( 5 | 11 | 12 | 13 | 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/icons/priority-none.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const PriorityNoneIcon = ({ className }: { className?: string }) => { 4 | return ( 5 | 11 | 12 | 13 | 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/icons/priority-urgent.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const PriorityUrgentIcon = ({ className }: { className?: string }) => { 4 | return ( 5 | 11 | 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/icons/sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const SidebarIcon = ({ className }: { className?: string }) => { 4 | return ( 5 | 11 | 16 | 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/icons/todo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const TodoIcon = ({ className }: { className?: string }) => { 4 | return ( 5 | 11 | 20 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/board/card.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar } from "@/components/common/avatar"; 2 | import { PriorityMenu } from "@/components/common/priority-menu"; 3 | import { StatusMenu } from "@/components/common/status-menu"; 4 | import { Issue, events } from "@/lib/livestore/schema"; 5 | import { Priority } from "@/types/priority"; 6 | import { Status } from "@/types/status"; 7 | import { getIssueTag } from "@/utils/get-issue-tag"; 8 | import { useStore } from "@livestore/react"; 9 | import React from "react"; 10 | import { Button } from "react-aria-components"; 11 | import { useNavigate } from "react-router-dom"; 12 | 13 | export const Card = ({ 14 | issue, 15 | className 16 | }: { 17 | issue: Issue; 18 | className?: string; 19 | }) => { 20 | const navigate = useNavigate(); 21 | const { store } = useStore(); 22 | 23 | const handleChangeStatus = (status: Status) => 24 | store.commit( 25 | events.updateIssueStatus({ id: issue.id, status, modified: new Date() }) 26 | ); 27 | 28 | const handleChangePriority = (priority: Priority) => 29 | store.commit( 30 | events.updateIssuePriority({ 31 | id: issue.id, 32 | priority, 33 | modified: new Date() 34 | }) 35 | ); 36 | 37 | return ( 38 |
navigate(`/issue/${issue.id}`)} 41 | > 42 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/board/draggable.tsx: -------------------------------------------------------------------------------- 1 | import { Issue } from "@/lib/livestore/schema"; 2 | import type { CSSProperties } from "react"; 3 | import React, { memo } from "react"; 4 | import { DragPreview, useDrag } from "react-aria"; 5 | import { Card } from "./card"; 6 | 7 | export const Draggable = memo( 8 | ({ issue, style }: { issue: Issue; style: CSSProperties }) => { 9 | const preview = React.useRef(null); 10 | const { dragProps, isDragging } = useDrag({ 11 | preview, 12 | getItems: () => [{ "text/plain": issue.id.toString() }] 13 | }); 14 | 15 | return ( 16 |
22 |
23 | 24 | {isDragging && ( 25 |
26 |
27 |
28 | )} 29 | 30 | {() => ( 31 |
32 | 33 |
34 | )} 35 |
36 |
37 |
38 | ); 39 | } 40 | ); 41 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/board/index.tsx: -------------------------------------------------------------------------------- 1 | import { Column } from "@/components/layout/board/column"; 2 | import { Filters } from "@/components/layout/filters"; 3 | import { statusOptions } from "@/data/status-options"; 4 | import { filterState$ } from "@/lib/livestore/queries"; 5 | import { tables } from "@/lib/livestore/schema"; 6 | import { 7 | filterStateToOrderBy, 8 | filterStateToWhere 9 | } from "@/lib/livestore/utils"; 10 | import { Status } from "@/types/status"; 11 | import { queryDb } from "@livestore/livestore"; 12 | import { useStore } from "@livestore/react"; 13 | import React from "react"; 14 | 15 | const filteredIssueIds$ = queryDb( 16 | (get) => 17 | tables.issue 18 | .select("id") 19 | .where({ ...filterStateToWhere(get(filterState$)), deleted: null }) 20 | .orderBy(filterStateToOrderBy(get(filterState$))), 21 | { label: "Board.visibleIssueIds" } 22 | ); 23 | 24 | export const Board = () => { 25 | const { store } = useStore(); 26 | const filteredIssueIds = store.useQuery(filteredIssueIds$); 27 | 28 | return ( 29 | <> 30 | 35 |
36 |
37 | {statusOptions.map((statusDetails, statusOption) => ( 38 | 43 | ))} 44 |
45 |
46 |
47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/filters/filter-menu.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, IconName } from "@/components/icons"; 2 | import { priorityOptions } from "@/data/priority-options"; 3 | import { statusOptions } from "@/data/status-options"; 4 | import { useFilterState } from "@/lib/livestore/queries"; 5 | import { Priority } from "@/types/priority"; 6 | import { Status } from "@/types/status"; 7 | import { CheckIcon } from "@heroicons/react/16/solid"; 8 | import React from "react"; 9 | import { 10 | Header, 11 | Menu, 12 | MenuItem, 13 | MenuSection, 14 | MenuTrigger, 15 | Popover, 16 | Separator 17 | } from "react-aria-components"; 18 | 19 | export const FilterMenu = ({ 20 | type, 21 | children 22 | }: { 23 | type?: "status" | "priority"; 24 | children?: React.ReactNode; 25 | }) => { 26 | const [filterState, setFilterState] = useFilterState(); 27 | 28 | const toggleFilter = ({ 29 | type, 30 | value 31 | }: 32 | | { type: "status"; value: Status } 33 | | { type: "priority"; value: Priority }) => { 34 | let filters: (Status | Priority)[] | undefined = [ 35 | ...(filterState[type] ?? []) 36 | ]; 37 | if (filters.includes(value)) filters.splice(filters.indexOf(value), 1); 38 | else filters.push(value); 39 | if (!filters.length) filters = undefined; 40 | setFilterState({ [type]: filters }); 41 | }; 42 | 43 | return ( 44 | 45 | {children} 46 | 47 | 48 | {type !== "priority" && ( 49 | 50 |
51 | Status 52 |
53 | {statusOptions.map(({ name, icon, style }, statusOption) => { 54 | const active = filterState.status?.includes( 55 | statusOption as Status 56 | ); 57 | return ( 58 | 61 | toggleFilter({ 62 | type: "status", 63 | value: statusOption as Status 64 | }) 65 | } 66 | className="group/item p-2 pl-9 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-700 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-700 cursor-pointer flex items-center gap-2" 67 | > 68 |
71 | {active && } 72 |
73 | 77 | {name} 78 |
79 | ); 80 | })} 81 |
82 | )} 83 | {!type && ( 84 | 85 | )} 86 | {type !== "status" && ( 87 | 88 |
89 | Priority 90 |
91 | {priorityOptions.map(({ name, icon, style }, priorityOption) => { 92 | const active = filterState.priority?.includes( 93 | priorityOption as Priority 94 | ); 95 | return ( 96 | 99 | toggleFilter({ 100 | type: "priority", 101 | value: priorityOption as Priority 102 | }) 103 | } 104 | className="group/item p-2 pl-9 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-700 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-700 cursor-pointer flex items-center gap-2" 105 | > 106 |
109 | {active && } 110 |
111 | 115 | {name} 116 |
117 | ); 118 | })} 119 |
120 | )} 121 |
122 |
123 |
124 | ); 125 | }; 126 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/filters/header.tsx: -------------------------------------------------------------------------------- 1 | import { MenuButton } from "@/components/common/menu-button"; 2 | import React from "react"; 3 | 4 | export const Header = ({ 5 | totalCount, 6 | filteredCount, 7 | heading 8 | }: { 9 | totalCount: number; 10 | filteredCount: number; 11 | heading: string; 12 | }) => { 13 | return ( 14 |
15 | 16 |
{heading}
17 |
18 | {filteredCount} 19 | {filteredCount !== totalCount && of {totalCount}} 20 | {heading !== "Issues" && issues} 21 |
22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/filters/index.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from "@/components/icons"; 2 | import { FilterMenu } from "@/components/layout/filters/filter-menu"; 3 | import { Header } from "@/components/layout/filters/header"; 4 | import { PriorityFilter } from "@/components/layout/filters/priority-filter"; 5 | import { SortMenu } from "@/components/layout/filters/sort-menu"; 6 | import { StatusFilter } from "@/components/layout/filters/status-filter"; 7 | import { SearchBar } from "@/components/layout/search/search-bar"; 8 | import { statusOptions } from "@/data/status-options"; 9 | import { issueCount$, useFilterState } from "@/lib/livestore/queries"; 10 | import { Status } from "@/types/status"; 11 | import { useStore } from "@livestore/react"; 12 | import React from "react"; 13 | import { Button } from "react-aria-components"; 14 | 15 | export const Filters = ({ 16 | filteredCount, 17 | hideStatusFilter, 18 | hideSorting, 19 | search 20 | }: { 21 | filteredCount: number; 22 | hideStatusFilter?: boolean; 23 | hideSorting?: boolean; 24 | search?: boolean; 25 | }) => { 26 | const { store } = useStore(); 27 | const totalCount = store.useQuery(issueCount$); 28 | const [filterState] = useFilterState(); 29 | 30 | return ( 31 | <> 32 | {search ? ( 33 | 34 | ) : ( 35 |
44 | )} 45 |
46 |
47 | {search && ( 48 |
49 | {filteredCount} 50 | {filteredCount !== totalCount && of {totalCount}} 51 | Issues 52 |
53 | )} 54 | 55 | 70 | 71 |
72 | {!hideStatusFilter && } 73 | 74 |
75 |
76 | {/* TODO add clear filters/sorting button */} 77 | {!hideSorting && } 78 |
79 | {filterState.status?.length || filterState.priority?.length ? ( 80 |
81 |
82 | {!hideStatusFilter && } 83 | 84 |
85 |
86 |
87 | ) : null} 88 | 89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/filters/priority-filter.tsx: -------------------------------------------------------------------------------- 1 | import { IconName } from "@/components/icons"; 2 | 3 | import { Icon } from "@/components/icons"; 4 | import { FilterMenu } from "@/components/layout/filters/filter-menu"; 5 | import { priorityOptions } from "@/data/priority-options"; 6 | import { useFilterState } from "@/lib/livestore/queries"; 7 | import { Priority } from "@/types/priority"; 8 | import { XMarkIcon } from "@heroicons/react/16/solid"; 9 | import React from "react"; 10 | import { Button } from "react-aria-components"; 11 | 12 | export const PriorityFilter = () => { 13 | const [filterState, setFilterState] = useFilterState(); 14 | if (!filterState.priority) return null; 15 | 16 | return ( 17 |
18 |
19 | 20 | Priority 21 | 22 | {filterState.priority.length > 1 ? "is any of" : "is"} 23 |
24 | 25 | 43 | 44 | 50 |
51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/filters/sort-menu.tsx: -------------------------------------------------------------------------------- 1 | import { Shortcut } from "@/components/common/shortcut"; 2 | import { 3 | SortingDirection, 4 | SortingOption, 5 | sortingOptions 6 | } from "@/data/sorting-options"; 7 | import { useFilterState } from "@/lib/livestore/queries"; 8 | import { 9 | ArrowsUpDownIcon, 10 | BarsArrowDownIcon, 11 | BarsArrowUpIcon 12 | } from "@heroicons/react/20/solid"; 13 | import React from "react"; 14 | import { useKeyboard } from "react-aria"; 15 | import { 16 | Button, 17 | Header, 18 | Menu, 19 | MenuItem, 20 | MenuSection, 21 | MenuTrigger, 22 | Popover 23 | } from "react-aria-components"; 24 | 25 | export const SortMenu = ({ type }: { type?: "status" | "priority" }) => { 26 | const [filterState, setFilterState] = useFilterState(); 27 | const [isOpen, setIsOpen] = React.useState(false); 28 | 29 | const toggleSorting = (sortingOption: SortingOption) => { 30 | const currentSorting = filterState.orderBy; 31 | const currentDirection = filterState.orderDirection; 32 | if (currentSorting === sortingOption) 33 | setFilterState({ 34 | orderDirection: currentDirection === "asc" ? "desc" : "asc" 35 | }); 36 | else 37 | setFilterState({ 38 | orderBy: sortingOption, 39 | orderDirection: sortingOptions[sortingOption as SortingOption] 40 | .defaultDirection as SortingDirection 41 | }); 42 | }; 43 | 44 | const { keyboardProps } = useKeyboard({ 45 | onKeyDown: (e) => { 46 | if (e.key === "Escape") { 47 | setIsOpen(false); 48 | return; 49 | } 50 | Object.entries(sortingOptions).forEach( 51 | ([sortingOption, { shortcut }]) => { 52 | if (e.key === shortcut) { 53 | toggleSorting(sortingOption as SortingOption); 54 | return; 55 | } 56 | } 57 | ); 58 | } 59 | }); 60 | 61 | return ( 62 | 63 | 71 | 72 | 77 | {type !== "priority" && ( 78 | 79 |
80 | Sorting 81 |
82 | {Object.entries(sortingOptions).map( 83 | ([sortingOption, { name, shortcut }]) => { 84 | return ( 85 | 88 | toggleSorting(sortingOption as SortingOption) 89 | } 90 | className="group/item p-2 rounded-md flex items-center gap-2 hover:bg-neutral-100 dark:hover:bg-neutral-700 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-700 cursor-pointer" 91 | > 92 | {name} 93 | {filterState.orderBy === sortingOption && ( 94 | <> 95 |
96 | {filterState.orderDirection === "asc" && ( 97 | 98 | )} 99 | {filterState.orderDirection === "desc" && ( 100 | 101 | )} 102 |
103 | 104 | )} 105 | 109 |
110 | ); 111 | } 112 | )} 113 |
114 | )} 115 |
116 |
117 |
118 | ); 119 | }; 120 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/filters/status-filter.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, IconName } from "@/components/icons"; 2 | import { FilterMenu } from "@/components/layout/filters/filter-menu"; 3 | import { statusOptions } from "@/data/status-options"; 4 | import { useFilterState } from "@/lib/livestore/queries"; 5 | import { Status } from "@/types/status"; 6 | import { XMarkIcon } from "@heroicons/react/16/solid"; 7 | import React from "react"; 8 | import { Button } from "react-aria-components"; 9 | 10 | export const StatusFilter = () => { 11 | const [filterState, setFilterState] = useFilterState(); 12 | if (!filterState.status) return null; 13 | 14 | return ( 15 |
16 |
17 | 18 | Status 19 | 20 | {filterState.status.length > 1 ? "is any of" : "is"} 21 |
22 | 23 | 43 | 44 | 50 |
51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import { MobileMenu } from "@/components/layout/sidebar/mobile-menu"; 2 | import { Toolbar } from "@/components/layout/toolbar"; 3 | import { useFrontendState } from "@/lib/livestore/queries"; 4 | import React from "react"; 5 | 6 | export const Layout = ({ children }: { children: React.ReactNode }) => { 7 | const [frontendState] = useFrontendState(); 8 | 9 | return ( 10 |
11 |
14 | {children} 15 |
16 | {frontendState.showToolbar && } 17 | 18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/issue/back-button.tsx: -------------------------------------------------------------------------------- 1 | import { XMarkIcon } from "@heroicons/react/20/solid"; 2 | import React from "react"; 3 | import { Button } from "react-aria-components"; 4 | 5 | export const BackButton = ({ close }: { close: () => void }) => { 6 | React.useEffect(() => { 7 | const handleKeyDown = (e: KeyboardEvent) => { 8 | if (e.key === "Escape") close(); 9 | }; 10 | window.addEventListener("keydown", handleKeyDown); 11 | return () => window.removeEventListener("keydown", handleKeyDown); 12 | }, [close]); 13 | 14 | return ( 15 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/issue/comment-input.tsx: -------------------------------------------------------------------------------- 1 | import Editor from "@/components/common/editor"; 2 | import { useFrontendState } from "@/lib/livestore/queries"; 3 | import { events } from "@/lib/livestore/schema"; 4 | import { ArrowUpIcon } from "@heroicons/react/20/solid"; 5 | import { useStore } from "@livestore/react"; 6 | import React from "react"; 7 | import { useKeyboard } from "react-aria"; 8 | import { Button } from "react-aria-components"; 9 | 10 | export const CommentInput = ({ 11 | issueId, 12 | className 13 | }: { 14 | issueId: number; 15 | className?: string; 16 | }) => { 17 | // TODO move this into LiveStore 18 | const [commentDraft, setCommentDraft] = React.useState(""); 19 | const [frontendState] = useFrontendState(); 20 | const { store } = useStore(); 21 | 22 | const { keyboardProps } = useKeyboard({ 23 | onKeyDown: (e) => { 24 | if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { 25 | submitComment(); 26 | } 27 | } 28 | }); 29 | 30 | const submitComment = () => { 31 | if (!commentDraft) return; 32 | store.commit( 33 | events.createComment({ 34 | id: crypto.randomUUID(), 35 | body: commentDraft, 36 | issueId: issueId, 37 | created: new Date(), 38 | creator: frontendState.user 39 | }) 40 | ); 41 | setCommentDraft(""); 42 | }; 43 | 44 | return ( 45 |
49 | setCommentDraft(value)} 53 | placeholder="Leave a comment..." 54 | /> 55 | {/* TODO add tooltip for submit shortcut */} 56 | 63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/issue/comments.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar } from "@/components/common/avatar"; 2 | import { tables } from "@/lib/livestore/schema"; 3 | import { formatDate } from "@/utils/format-date"; 4 | import { queryDb } from "@livestore/livestore"; 5 | import { useStore } from "@livestore/react"; 6 | import React from "react"; 7 | import ReactMarkdown from "react-markdown"; 8 | 9 | export const Comments = ({ issueId }: { issueId: number }) => { 10 | const { store } = useStore(); 11 | const comments = store.useQuery( 12 | queryDb( 13 | tables.comment.where("issueId", issueId).orderBy("created", "desc"), 14 | { deps: [issueId] } 15 | ) 16 | ); 17 | 18 | return ( 19 |
    20 | {comments.map(({ id, body, creator, created }) => ( 21 |
  • 25 |
    26 | 27 |
    {creator}
    28 | {/* TODO: make this a relative date */} 29 |
    30 | {formatDate(new Date(created))} 31 |
    32 |
    33 |
    34 | {body} 35 |
    36 |
  • 37 | ))} 38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/issue/delete-button.tsx: -------------------------------------------------------------------------------- 1 | import { events } from "@/lib/livestore/schema"; 2 | import { TrashIcon } from "@heroicons/react/16/solid"; 3 | import { useStore } from "@livestore/react"; 4 | import React from "react"; 5 | import { Button } from "react-aria-components"; 6 | 7 | export const DeleteButton = ({ 8 | issueId, 9 | close, 10 | className 11 | }: { 12 | issueId: number; 13 | close: () => void; 14 | className?: string; 15 | }) => { 16 | const { store } = useStore(); 17 | const [confirm, setConfirm] = React.useState(false); 18 | 19 | const onClick = () => { 20 | if (confirm) { 21 | const deleted = new Date(); 22 | store.commit( 23 | events.deleteIssue({ id: issueId, deleted }), 24 | events.deleteDescription({ id: issueId, deleted }), 25 | events.deleteCommentsByIssueId({ issueId, deleted }) 26 | ); 27 | setConfirm(false); 28 | close(); 29 | } 30 | setConfirm(true); 31 | setTimeout(() => { 32 | setConfirm(false); 33 | }, 2000); 34 | }; 35 | 36 | return ( 37 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/issue/description-input.tsx: -------------------------------------------------------------------------------- 1 | import Editor from "@/components/common/editor"; 2 | import React from "react"; 3 | 4 | export const DescriptionInput = ({ 5 | description, 6 | setDescription, 7 | className 8 | }: { 9 | description: string; 10 | setDescription: (description: string) => void; 11 | className?: string; 12 | }) => ( 13 | setDescription(value)} 17 | placeholder="Add description..." 18 | /> 19 | ); 20 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/issue/new-issue-modal.tsx: -------------------------------------------------------------------------------- 1 | import { NewIssueModalContext } from "@/app/contexts"; 2 | import { Modal } from "@/components/common/modal"; 3 | import { PriorityMenu } from "@/components/common/priority-menu"; 4 | import { StatusMenu } from "@/components/common/status-menu"; 5 | import { DescriptionInput } from "@/components/layout/issue/description-input"; 6 | import { TitleInput } from "@/components/layout/issue/title-input"; 7 | import { highestIssueId$, useFrontendState } from "@/lib/livestore/queries"; 8 | import { events, tables } from "@/lib/livestore/schema"; 9 | import { Priority } from "@/types/priority"; 10 | import { Status } from "@/types/status"; 11 | import { useStore } from "@livestore/react"; 12 | import { generateKeyBetween } from "fractional-indexing"; 13 | import React from "react"; 14 | import { Button } from "react-aria-components"; 15 | 16 | export const NewIssueModal = () => { 17 | const [frontendState] = useFrontendState(); 18 | const { newIssueModalStatus, setNewIssueModalStatus } = 19 | React.useContext(NewIssueModalContext)!; 20 | const [title, setTitle] = React.useState(""); 21 | const [description, setDescription] = React.useState(""); 22 | const [priority, setPriority] = React.useState(0); 23 | const { store } = useStore(); 24 | 25 | const closeModal = () => { 26 | setTitle(""); 27 | setDescription(""); 28 | setPriority(0); 29 | setNewIssueModalStatus(false); 30 | }; 31 | 32 | const createIssue = () => { 33 | if (!title) return; 34 | const date = new Date(); 35 | // TODO make this "merge safe" 36 | const highestIssueId = store.query(highestIssueId$); 37 | const highestKanbanOrder = store.query( 38 | tables.issue 39 | .select("kanbanorder") 40 | .where({ 41 | status: 42 | newIssueModalStatus === false ? 0 : (newIssueModalStatus as Status) 43 | }) 44 | .orderBy("kanbanorder", "desc") 45 | .first({ fallback: () => "a1" }) 46 | ); 47 | const kanbanorder = generateKeyBetween(highestKanbanOrder, null); 48 | store.commit( 49 | events.createIssueWithDescription({ 50 | id: highestIssueId + 1, 51 | title, 52 | priority, 53 | status: newIssueModalStatus as Status, 54 | modified: date, 55 | created: date, 56 | creator: frontendState.user, 57 | kanbanorder, 58 | description 59 | }) 60 | ); 61 | closeModal(); 62 | }; 63 | 64 | return ( 65 | 66 |
67 |

68 | New issue 69 |

70 | 76 | 81 |
82 | 91 | 96 | 103 |
104 |
105 |
106 | ); 107 | }; 108 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/issue/title-input.tsx: -------------------------------------------------------------------------------- 1 | import { events } from "@/lib/livestore/schema"; 2 | import { Issue } from "@/types/issue"; 3 | import { useStore } from "@livestore/react"; 4 | import React from "react"; 5 | 6 | export const TitleInput = ({ 7 | issue, 8 | title, 9 | setTitle, 10 | className, 11 | autoFocus 12 | }: { 13 | issue?: Issue; 14 | title?: string; 15 | setTitle?: (title: string) => void; 16 | className?: string; 17 | autoFocus?: boolean; 18 | }) => { 19 | const { store } = useStore(); 20 | 21 | const handleTitleChange = (title: string) => { 22 | if (issue) 23 | store.commit( 24 | events.updateIssueTitle({ id: issue.id, title, modified: new Date() }) 25 | ); 26 | if (setTitle) setTitle(title); 27 | }; 28 | 29 | return ( 30 | handleTitleChange(e.target.value)} 36 | onBlur={(e) => handleTitleChange(e.target.value)} 37 | /> 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/list/filtered-list.tsx: -------------------------------------------------------------------------------- 1 | import { VirtualRow } from "@/components/layout/list/virtual-row"; 2 | import { useDebouncedScrollState } from "@/lib/livestore/queries"; 3 | import React from "react"; 4 | import AutoSizer from "react-virtualized-auto-sizer"; 5 | import { FixedSizeList } from "react-window"; 6 | 7 | export const FilteredList = ({ 8 | filteredIssueIds 9 | }: { 10 | filteredIssueIds: readonly number[]; 11 | }) => { 12 | const [scrollState, setScrollState] = 13 | useDebouncedScrollState("filtered-list"); 14 | 15 | return ( 16 |
17 | 18 | {({ height, width }: { width: number; height: number }) => ( 19 | setScrollState({ list: e.scrollOffset })} 27 | initialScrollOffset={scrollState.list ?? 0} 28 | > 29 | {VirtualRow} 30 | 31 | )} 32 | 33 |
34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/list/index.tsx: -------------------------------------------------------------------------------- 1 | import { Filters } from "@/components/layout/filters"; 2 | import { FilteredList } from "@/components/layout/list/filtered-list"; 3 | import { filterState$ } from "@/lib/livestore/queries"; 4 | import { tables } from "@/lib/livestore/schema"; 5 | import { 6 | filterStateToOrderBy, 7 | filterStateToWhere 8 | } from "@/lib/livestore/utils"; 9 | import { queryDb } from "@livestore/livestore"; 10 | import { useStore } from "@livestore/react"; 11 | import React from "react"; 12 | 13 | const filteredIssueIds$ = queryDb( 14 | (get) => 15 | tables.issue 16 | .select("id") 17 | .where({ ...filterStateToWhere(get(filterState$)), deleted: null }) 18 | .orderBy(filterStateToOrderBy(get(filterState$))), 19 | { label: "List.visibleIssueIds" } 20 | ); 21 | 22 | export const List = () => { 23 | const { store } = useStore(); 24 | const filteredIssueIds = store.useQuery(filteredIssueIds$); 25 | 26 | return ( 27 | <> 28 | 29 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/list/row.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar } from "@/components/common/avatar"; 2 | import { PriorityMenu } from "@/components/common/priority-menu"; 3 | import { StatusMenu } from "@/components/common/status-menu"; 4 | import { events } from "@/lib/livestore/schema"; 5 | import { Issue } from "@/types/issue"; 6 | import { Priority } from "@/types/priority"; 7 | import { Status } from "@/types/status"; 8 | import { formatDate } from "@/utils/format-date"; 9 | import { getIssueTag } from "@/utils/get-issue-tag"; 10 | import { useStore } from "@livestore/react"; 11 | import type { CSSProperties } from "react"; 12 | import React, { memo } from "react"; 13 | import { useNavigate } from "react-router-dom"; 14 | 15 | export const Row = memo( 16 | ({ issue, style }: { issue: Issue; style: CSSProperties }) => { 17 | const navigate = useNavigate(); 18 | const { store } = useStore(); 19 | 20 | const handleChangeStatus = (status: Status) => 21 | store.commit( 22 | events.updateIssueStatus({ id: issue.id, status, modified: new Date() }) 23 | ); 24 | 25 | const handleChangePriority = (priority: Priority) => 26 | store.commit( 27 | events.updateIssuePriority({ 28 | id: issue.id, 29 | priority, 30 | modified: new Date() 31 | }) 32 | ); 33 | 34 | return ( 35 |
navigate(`/issue/${issue.id}`)} 40 | style={style} 41 | > 42 |
43 | 47 |
48 | {getIssueTag(issue.id)} 49 |
50 | 54 |
55 | {issue.title} 56 |
57 |
58 |
59 |
60 | {formatDate(new Date(issue.created))} 61 |
62 | 63 |
64 |
65 | ); 66 | } 67 | ); 68 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/list/virtual-row.tsx: -------------------------------------------------------------------------------- 1 | import { Row } from "@/components/layout/list/row"; 2 | import { tables } from "@/lib/livestore/schema"; 3 | import { useStore } from "@livestore/react"; 4 | import { queryDb } from "@livestore/livestore"; 5 | import React, { memo, type CSSProperties } from "react"; 6 | import { areEqual } from "react-window"; 7 | 8 | export const VirtualRow = memo( 9 | ({ 10 | data, 11 | index, 12 | style 13 | }: { 14 | data: readonly number[]; 15 | index: number; 16 | style: CSSProperties; 17 | }) => { 18 | const { store } = useStore(); 19 | const issue = store.useQuery( 20 | queryDb(tables.issue.where({ id: data[index]! }).first(), { 21 | deps: [data[index]] 22 | }) 23 | ); 24 | return ; 25 | }, 26 | areEqual 27 | ); 28 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/search/index.tsx: -------------------------------------------------------------------------------- 1 | import { Filters } from "@/components/layout/filters"; 2 | import { FilteredList } from "@/components/layout/list/filtered-list"; 3 | import { filterState$, useFilterState } from "@/lib/livestore/queries"; 4 | import { tables } from "@/lib/livestore/schema"; 5 | import { 6 | filterStateToOrderBy, 7 | filterStateToWhere 8 | } from "@/lib/livestore/utils"; 9 | import { queryDb } from "@livestore/livestore"; 10 | import { useStore } from "@livestore/react"; 11 | import React from "react"; 12 | 13 | const filteredIssueIds$ = queryDb( 14 | (get) => 15 | tables.issue 16 | .select("id") 17 | .where({ ...filterStateToWhere(get(filterState$)), deleted: null }) 18 | .orderBy(filterStateToOrderBy(get(filterState$))), 19 | { label: "List.visibleIssueIds" } 20 | ); 21 | 22 | export const Search = () => { 23 | const { store } = useStore(); 24 | const filteredIssueIds = store.useQuery(filteredIssueIds$); 25 | const [filterState] = useFilterState(); 26 | 27 | return ( 28 | <> 29 | 33 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/search/search-bar.tsx: -------------------------------------------------------------------------------- 1 | import { MenuButton } from "@/components/common/menu-button"; 2 | import { useFilterState } from "@/lib/livestore/queries"; 3 | import { MagnifyingGlassIcon } from "@heroicons/react/16/solid"; 4 | import { XMarkIcon } from "@heroicons/react/20/solid"; 5 | import React from "react"; 6 | import { useKeyboard } from "react-aria"; 7 | import { Button, Input } from "react-aria-components"; 8 | 9 | export const SearchBar = () => { 10 | const [filterState, setFilterState] = useFilterState(); 11 | 12 | const { keyboardProps } = useKeyboard({ 13 | onKeyDown: (e) => { 14 | if (e.key === "Escape") (e.target as HTMLInputElement)?.blur(); 15 | } 16 | }); 17 | 18 | return ( 19 |
20 | 21 | 22 | setFilterState({ query: e.target.value })} 29 | {...keyboardProps} 30 | /> 31 | {filterState.query && ( 32 | 39 | )} 40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/sidebar/about-menu.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from "@/components/icons"; 2 | import { AboutModal } from "@/components/layout/sidebar/about-modal"; 3 | import { ChevronDownIcon } from "@heroicons/react/16/solid"; 4 | import React from "react"; 5 | import { 6 | Button, 7 | Header, 8 | Menu, 9 | MenuItem, 10 | MenuSection, 11 | MenuTrigger, 12 | Popover, 13 | Separator 14 | } from "react-aria-components"; 15 | 16 | export const AboutMenu = () => { 17 | const [showAboutModal, setShowAboutModal] = React.useState(false); 18 | 19 | return ( 20 | <> 21 | 22 | 33 | 34 | 35 | 36 |
37 | LinearLite 38 |
39 | setShowAboutModal(true)} 41 | className="p-2 rounded-md hover:bg-neutral-100 focus:outline-none focus:bg-neutral-100 dark:hover:bg-neutral-700 dark:focus:bg-neutral-700 cursor-pointer" 42 | > 43 | About 44 | 45 |
46 | 47 | 48 |
49 | LiveStore 50 |
51 | 52 | About 53 | 54 | 55 | Documentation 56 | 57 | 58 | GitHub 59 | 60 |
61 |
62 |
63 |
64 | 65 | 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/sidebar/about-modal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal } from "@/components/common/modal"; 2 | import React from "react"; 3 | import { Link } from "react-router-dom"; 4 | 5 | export const AboutModal = ({ 6 | show, 7 | setShow 8 | }: { 9 | show: boolean; 10 | setShow: (show: boolean) => void; 11 | }) => { 12 | return ( 13 | 14 |
15 |

16 | LinearLite is an example of a collaboration application using a 17 | local-first approach, obviously inspired by{" "} 18 | 23 | Linear 24 | 25 | . 26 |

27 |

28 | It's built using{" "} 29 | 34 | LiveStore 35 | 36 | , a local-first sync layer for web and mobile apps. 37 |

38 |
39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import { MenuContext } from "@/app/contexts"; 2 | import { AboutMenu } from "@/components/layout/sidebar/about-menu"; 3 | import { NewIssueButton } from "@/components/layout/sidebar/new-issue-button"; 4 | import { SearchButton } from "@/components/layout/sidebar/search-button"; 5 | import { ThemeButton } from "@/components/layout/sidebar/theme-button"; 6 | import { ToolbarButton } from "@/components/layout/toolbar/toolbar-button"; 7 | import { useFilterState } from "@/lib/livestore/queries"; 8 | import { Bars4Icon, ViewColumnsIcon } from "@heroicons/react/24/outline"; 9 | import React from "react"; 10 | import { Link } from "react-router-dom"; 11 | 12 | export const Sidebar = ({ className }: { className?: string }) => { 13 | const [, setFilterState] = useFilterState(); 14 | const { setShowMenu } = React.useContext(MenuContext)!; 15 | 16 | const navItems = [ 17 | { 18 | title: "List view", 19 | icon: Bars4Icon, 20 | href: "/", 21 | onClick: () => setFilterState({ status: null }) 22 | }, 23 | { 24 | title: "Board view", 25 | icon: ViewColumnsIcon, 26 | href: "/board", 27 | onClick: () => setFilterState({ status: null }) 28 | } 29 | ]; 30 | 31 | return ( 32 | 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/sidebar/mobile-menu.tsx: -------------------------------------------------------------------------------- 1 | import { MenuContext } from "@/app/contexts"; 2 | import { Sidebar } from "@/components/layout/sidebar"; 3 | import { useFrontendState } from "@/lib/livestore/queries"; 4 | import React from "react"; 5 | import { ModalOverlay, Modal as ReactAriaModal } from "react-aria-components"; 6 | 7 | export const MobileMenu = () => { 8 | const { showMenu, setShowMenu } = React.useContext(MenuContext)!; 9 | const [frontendState] = useFrontendState(); 10 | 11 | return ( 12 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/sidebar/new-issue-button.tsx: -------------------------------------------------------------------------------- 1 | import { MenuContext, NewIssueModalContext } from "@/app/contexts"; 2 | import { Icon } from "@/components/icons"; 3 | import { Status } from "@/types/status"; 4 | import { PlusIcon } from "@heroicons/react/20/solid"; 5 | import React from "react"; 6 | import { Button } from "react-aria-components"; 7 | 8 | export const NewIssueButton = ({ status }: { status?: Status }) => { 9 | const { setNewIssueModalStatus } = React.useContext(NewIssueModalContext)!; 10 | const { setShowMenu } = React.useContext(MenuContext)!; 11 | 12 | return ( 13 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/sidebar/search-button.tsx: -------------------------------------------------------------------------------- 1 | import { MenuContext } from "@/app/contexts"; 2 | import { useFilterState } from "@/lib/livestore/queries"; 3 | import { MagnifyingGlassIcon } from "@heroicons/react/16/solid"; 4 | import React from "react"; 5 | import { Link } from "react-router-dom"; 6 | 7 | export const SearchButton = () => { 8 | const [, setFilterState] = useFilterState(); 9 | const { setShowMenu } = React.useContext(MenuContext)!; 10 | 11 | return ( 12 | { 16 | setFilterState({ query: null }); 17 | setShowMenu(false); 18 | }} 19 | className="rounded-lg size-8 flex items-center justify-center hover:bg-neutral-100 dark:hover:bg-neutral-800 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800" 20 | > 21 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/sidebar/theme-button.tsx: -------------------------------------------------------------------------------- 1 | import { Shortcut } from "@/components/common/shortcut"; 2 | import { Theme, themeOptions } from "@/data/theme-options"; 3 | import { useFrontendState } from "@/lib/livestore/queries"; 4 | import { CheckIcon, MoonIcon, SunIcon } from "@heroicons/react/16/solid"; 5 | import { ComputerDesktopIcon } from "@heroicons/react/20/solid"; 6 | import React from "react"; 7 | import { useKeyboard } from "react-aria"; 8 | import { 9 | Button, 10 | Menu, 11 | MenuItem, 12 | MenuTrigger, 13 | Popover 14 | } from "react-aria-components"; 15 | 16 | export const ThemeButton = () => { 17 | const [theme, setTheme] = React.useState(undefined); 18 | const [isOpen, setIsOpen] = React.useState(false); 19 | const [frontendState, setFrontendState] = useFrontendState(); 20 | 21 | const selectTheme = (theme: Theme) => { 22 | setTheme(theme); 23 | setFrontendState({ theme }); 24 | if (theme === "system") localStorage.removeItem("theme"); 25 | else localStorage.theme = theme; 26 | document.documentElement.classList.toggle( 27 | "dark", 28 | localStorage.theme === "dark" || 29 | (!("theme" in localStorage) && 30 | window.matchMedia("(prefers-color-scheme: dark)").matches) 31 | ); 32 | }; 33 | 34 | const { keyboardProps } = useKeyboard({ 35 | onKeyDown: (e) => { 36 | if (e.key === "Escape") { 37 | setIsOpen(false); 38 | return; 39 | } 40 | themeOptions.forEach(({ id, shortcut }) => { 41 | if (e.key === shortcut) { 42 | selectTheme(id); 43 | setIsOpen(false); 44 | return; 45 | } 46 | }); 47 | } 48 | }); 49 | 50 | React.useEffect(() => { 51 | if (frontendState.theme) { 52 | setTheme( 53 | frontendState.theme === "system" ? undefined : frontendState.theme 54 | ); 55 | } 56 | }, [frontendState.theme]); 57 | 58 | return ( 59 | 60 | 67 | 68 | 69 | {themeOptions.map(({ id, label, shortcut }) => { 70 | return ( 71 | selectTheme(id)} 74 | className="group/item p-2 rounded-md flex items-center gap-2 hover:bg-neutral-100 dark:hover:bg-neutral-700 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-700 cursor-pointer" 75 | > 76 | {id === "light" && } 77 | {id === "dark" && } 78 | {id === "system" && ( 79 | 80 | )} 81 | {label} 82 | {id === theme && ( 83 | 84 | )} 85 | 86 | 87 | ); 88 | })} 89 | 90 | 91 | 92 | ); 93 | }; 94 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/toolbar/devtools-button.tsx: -------------------------------------------------------------------------------- 1 | import { CodeBracketIcon } from "@heroicons/react/16/solid"; 2 | import { useStore } from "@livestore/react"; 3 | import React from "react"; 4 | 5 | export const DevtoolsButton = ({ className }: { className?: string }) => { 6 | const { store } = useStore(); 7 | const devtoolsUrl = React.useMemo(() => { 8 | const searchParams = new URLSearchParams(); 9 | searchParams.set("storeId", store.storeId); 10 | searchParams.set("sessionId", store.sessionId); 11 | searchParams.set("clientId", store.clientId); 12 | return `${location.origin}/_livestore?${searchParams.toString()}`; 13 | }, [store.storeId, store.sessionId, store.clientId]); 14 | 15 | return ( 16 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/toolbar/download-button.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowDownIcon } from "@heroicons/react/16/solid"; 2 | import { useStore } from "@livestore/react"; 3 | import React from "react"; 4 | import { Button } from "react-aria-components"; 5 | 6 | export const DownloadButton = ({ className }: { className?: string }) => { 7 | const { store } = useStore(); 8 | const onClick = () => { 9 | (store as any)._dev.downloadDb(); 10 | }; 11 | 12 | return ( 13 |
14 | 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/toolbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from "@/components/icons"; 2 | import { DownloadButton } from "@/components/layout/toolbar/download-button"; 3 | import { ResetButton } from "@/components/layout/toolbar/reset-button"; 4 | import { SeedInput } from "@/components/layout/toolbar/seed-input"; 5 | import { ShareButton } from "@/components/layout/toolbar/share-button"; 6 | import { UserInput } from "@/components/layout/toolbar/user-input"; 7 | import { FPSMeter } from "@overengineering/fps-meter"; 8 | import React from "react"; 9 | import { Link } from "react-router-dom"; 10 | import { DevtoolsButton } from "./devtools-button"; 11 | import { SyncToggle } from "./sync-toggle"; 12 | 13 | export const Toolbar = () => { 14 | return ( 15 |
16 |
17 | 22 | 23 | LiveStore 24 | 25 | 26 |
27 |
28 | 29 | 30 |
31 |
32 | Database: 33 | 34 | 35 | 36 | {import.meta.env.DEV && } 37 |
38 | 39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/toolbar/mobile-menu.tsx: -------------------------------------------------------------------------------- 1 | import { ResetButton } from "@/components/layout/toolbar/reset-button"; 2 | import { SeedInput } from "@/components/layout/toolbar/seed-input"; 3 | import { UserInput } from "@/components/layout/toolbar/user-input"; 4 | import { ChevronUpIcon } from "@heroicons/react/16/solid"; 5 | import React from "react"; 6 | import { 7 | Button, 8 | DialogTrigger, 9 | ModalOverlay, 10 | Modal as ReactAriaModal 11 | } from "react-aria-components"; 12 | import { ShareButton } from "./share-button"; 13 | import { SyncToggle } from "./sync-toggle"; 14 | 15 | export const MobileMenu = () => { 16 | return ( 17 |
18 | 19 | 26 | 30 | 31 |
32 |
33 | Please use the desktop version to access all LiveStore tools! 34 |
35 | 36 | 37 | 38 | 39 | 40 |
41 |
42 |
43 |
44 |
45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/toolbar/reset-button.tsx: -------------------------------------------------------------------------------- 1 | import { TrashIcon } from "@heroicons/react/16/solid"; 2 | import React from "react"; 3 | import { Button } from "react-aria-components"; 4 | import { useNavigate } from "react-router-dom"; 5 | 6 | export const ResetButton = ({ className }: { className?: string }) => { 7 | const [confirm, setConfirm] = React.useState(false); 8 | const navigate = useNavigate(); 9 | 10 | const onClick = () => { 11 | if (confirm) { 12 | navigate("/?reset"); 13 | window.location.reload(); 14 | } 15 | setConfirm(true); 16 | setTimeout(() => { 17 | setConfirm(false); 18 | }, 2000); 19 | }; 20 | 21 | return ( 22 |
23 | 31 |
32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/toolbar/seed-input.tsx: -------------------------------------------------------------------------------- 1 | import { seed } from "@/lib/livestore/seed"; 2 | import { PlusIcon } from "@heroicons/react/16/solid"; 3 | import { useStore } from "@livestore/react"; 4 | import React from "react"; 5 | import { Button, Input } from "react-aria-components"; 6 | 7 | export const SeedInput = ({ className }: { className?: string }) => { 8 | const [count, setCount] = React.useState(50); 9 | const { store } = useStore(); 10 | 11 | const onClick = () => { 12 | if (count === 0) return; 13 | seed(store, count); 14 | }; 15 | 16 | return ( 17 |
18 | setCount(Number(e.target.value))} 25 | className="h-6 px-1.5 border-none rounded-l text-xs bg-neutral-800 placeholder:text-neutral-500 text-neutral-300 w-12 focus:outline-none focus:ring-0 focus:border-none hover:bg-neutral-700 focus:bg-neutral-700" 26 | /> 27 | 35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/toolbar/share-button.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon, LinkIcon, QrCodeIcon } from "@heroicons/react/16/solid"; 2 | import React from "react"; 3 | import { 4 | Button, 5 | ModalOverlay, 6 | Modal as ReactAriaModal 7 | } from "react-aria-components"; 8 | 9 | // This uses a public QR code API: https://goqr.me/api/doc/create-qr-code/ 10 | 11 | export const ShareButton = ({ className }: { className?: string }) => { 12 | const [copied, setCopied] = React.useState(false); 13 | const [showQR, setShowQR] = React.useState(false); 14 | 15 | // TODO build sharable workspace feature 16 | const copyUrl = () => { 17 | navigator.clipboard.writeText(window.location.href); 18 | setCopied(true); 19 | setTimeout(() => { 20 | setCopied(false); 21 | }, 2000); 22 | }; 23 | 24 | return ( 25 | <> 26 |
27 | Workspace: 28 | 50 | 57 |
58 | 64 | 65 | 71 | 72 | 73 | 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/toolbar/sync-toggle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Switch } from "react-aria-components"; 3 | 4 | export const SyncToggle = ({ className }: { className?: string }) => { 5 | // TODO hook up actual sync/network state 6 | const [sync, setSync] = React.useState(false); 7 | 8 | return ( 9 |
10 | {/* TODO add disabled tooltip for now */} 11 | 18 |
19 | 20 |
21 | 22 | Sync/Network 23 | 24 |
25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/toolbar/toolbar-button.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from "@/components/icons"; 2 | import { useFrontendState } from "@/lib/livestore/queries"; 3 | import React from "react"; 4 | import { Button } from "react-aria-components"; 5 | 6 | export const ToolbarButton = () => { 7 | const [frontendState, setFrontendState] = useFrontendState(); 8 | const onClick = () => { 9 | setFrontendState({ 10 | ...frontendState, 11 | showToolbar: !frontendState.showToolbar 12 | }); 13 | }; 14 | 15 | return ( 16 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /apps/linearlite/src/components/layout/toolbar/user-input.tsx: -------------------------------------------------------------------------------- 1 | import { useFrontendState } from "@/lib/livestore/queries"; 2 | import React from "react"; 3 | import { Input } from "react-aria-components"; 4 | 5 | export const UserInput = ({ className }: { className?: string }) => { 6 | const [frontendState, setFrontendState] = useFrontendState(); 7 | 8 | return ( 9 |
10 | User: 11 | 18 | setFrontendState({ ...frontendState, user: e.target.value }) 19 | } 20 | onBlur={() => 21 | setFrontendState({ 22 | ...frontendState, 23 | user: frontendState.user || "John Doe" 24 | }) 25 | } 26 | className="h-6 px-1.5 bg-transparent bg-neutral-800 hover:bg-neutral-700 border-none text-xs rounded placeholder:text-neutral-500 text-neutral-300 grow lg:grow-0 lg:w-28 focus:outline-none focus:ring-0 focus:border-none focus:bg-neutral-700" 27 | /> 28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /apps/linearlite/src/data/priority-options.ts: -------------------------------------------------------------------------------- 1 | import { IconName } from "@/components/icons"; 2 | 3 | export const priorityOptions: { 4 | name: string; 5 | icon: IconName; 6 | style: string; 7 | shortcut: string; 8 | }[] = [ 9 | { 10 | name: "None", 11 | icon: "priority-none", 12 | style: 13 | "text-neutral-500 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-300", 14 | shortcut: "0" 15 | }, 16 | { 17 | name: "Low", 18 | icon: "priority-low", 19 | style: 20 | "text-neutral-500 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-300", 21 | shortcut: "1" 22 | }, 23 | { 24 | name: "Medium", 25 | icon: "priority-medium", 26 | style: 27 | "text-neutral-500 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-300", 28 | shortcut: "2" 29 | }, 30 | { 31 | name: "High", 32 | icon: "priority-high", 33 | style: 34 | "text-neutral-500 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-300", 35 | shortcut: "3" 36 | }, 37 | { 38 | name: "Urgent", 39 | icon: "priority-urgent", 40 | style: 41 | "text-neutral-500 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-300", 42 | shortcut: "4" 43 | } 44 | ]; 45 | -------------------------------------------------------------------------------- /apps/linearlite/src/data/sorting-options.ts: -------------------------------------------------------------------------------- 1 | export const sortingOptions = { 2 | priority: { name: "Priority", shortcut: "p", defaultDirection: "desc" }, 3 | status: { name: "Status", shortcut: "s", defaultDirection: "asc" }, 4 | created: { name: "Created", shortcut: "c", defaultDirection: "desc" }, 5 | modified: { name: "Updated", shortcut: "u", defaultDirection: "desc" } 6 | }; 7 | 8 | export type SortingOption = keyof typeof sortingOptions; 9 | 10 | export type SortingDirection = "asc" | "desc"; 11 | -------------------------------------------------------------------------------- /apps/linearlite/src/data/status-options.ts: -------------------------------------------------------------------------------- 1 | import { IconName } from "@/components/icons"; 2 | 3 | export type StatusId = "backlog" | "todo" | "in_progress" | "done" | "canceled"; 4 | 5 | export type StatusDetails = { 6 | name: string; 7 | id: StatusId; 8 | icon: IconName; 9 | style: string; 10 | shortcut: string; 11 | }; 12 | 13 | export const statusOptions: StatusDetails[] = [ 14 | { 15 | name: "Backlog", 16 | id: "backlog", 17 | icon: "backlog", 18 | style: 19 | "text-neutral-400 group-hover:text-neutral-600 dark:group-hover:text-neutral-200", 20 | shortcut: "1" 21 | }, 22 | { 23 | name: "Todo", 24 | id: "todo", 25 | icon: "todo", 26 | style: 27 | "text-neutral-400 group-hover:text-neutral-600 dark:group-hover:text-neutral-200", 28 | shortcut: "2" 29 | }, 30 | { 31 | name: "In Progress", 32 | id: "in_progress", 33 | icon: "in-progress", 34 | style: 35 | "text-yellow-500 group-hover:text-yellow-700 dark:text-yellow-400 dark:group-hover:text-yellow-300", 36 | shortcut: "3" 37 | }, 38 | { 39 | name: "Done", 40 | id: "done", 41 | icon: "done", 42 | style: 43 | "text-indigo-500 group-hover:text-indigo-700 dark:text-indigo-400 dark:group-hover:text-indigo-300", 44 | shortcut: "4" 45 | }, 46 | { 47 | name: "Canceled", 48 | id: "canceled", 49 | icon: "canceled", 50 | style: 51 | "text-neutral-500 group-hover:text-neutral-700 dark:text-neutral-300 dark:group-hover:text-neutral-200", 52 | shortcut: "5" 53 | } 54 | ]; 55 | -------------------------------------------------------------------------------- /apps/linearlite/src/data/theme-options.ts: -------------------------------------------------------------------------------- 1 | export type ThemeOption = { 2 | id: string; 3 | label: string; 4 | }; 5 | 6 | export type Theme = "light" | "dark" | "system"; 7 | 8 | export const themeOptions: { id: Theme; label: string; shortcut: string }[] = [ 9 | { id: "light", label: "Light", shortcut: "l" }, 10 | { id: "dark", label: "Dark", shortcut: "d" }, 11 | { id: "system", label: "System", shortcut: "s" } 12 | ]; 13 | -------------------------------------------------------------------------------- /apps/linearlite/src/hooks/useClickOutside.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useCallback, useEffect } from "react"; 2 | 3 | export const useClickOutside = ( 4 | ref: RefObject, 5 | callback: (event: MouseEvent | TouchEvent) => void, 6 | outerRef?: RefObject 7 | ): void => { 8 | const handleClick = useCallback( 9 | (event: MouseEvent | TouchEvent) => { 10 | if (!event.target || outerRef?.current?.contains(event.target as Node)) { 11 | return; 12 | } 13 | if (ref.current && !ref.current.contains(event.target as Node)) { 14 | callback(event); 15 | } 16 | }, 17 | [callback, ref, outerRef] 18 | ); 19 | useEffect(() => { 20 | document.addEventListener("mousedown", handleClick); 21 | document.addEventListener("touchstart", handleClick); 22 | 23 | return () => { 24 | document.removeEventListener("mousedown", handleClick); 25 | document.removeEventListener("touchstart", handleClick); 26 | }; 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /apps/linearlite/src/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | type Timer = ReturnType; 4 | 5 | export const useDebounce = (func: (...args: any[]) => void, delay = 1000) => { 6 | const timer = useRef(undefined); 7 | 8 | useEffect(() => { 9 | return () => { 10 | if (!timer.current) return; 11 | clearTimeout(timer.current); 12 | }; 13 | }, []); 14 | 15 | const debouncedFunction = (...args: any[]) => { 16 | clearTimeout(timer.current); 17 | timer.current = setTimeout(() => { 18 | func(...args); 19 | }, delay); 20 | }; 21 | 22 | return debouncedFunction; 23 | }; 24 | -------------------------------------------------------------------------------- /apps/linearlite/src/hooks/useLockBodyScroll.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from "react"; 2 | 3 | export default function useLockBodyScroll() { 4 | useLayoutEffect(() => { 5 | // Get original value of body overflow 6 | const originalStyle = window.getComputedStyle(document.body).overflow; 7 | // Prevent scrolling on mount 8 | document.body.style.overflow = "hidden"; 9 | // Re-enable scrolling when component unmounts 10 | return () => { 11 | document.body.style.overflow = originalStyle; 12 | }; 13 | }, []); // Empty array ensures effect is only run on mount and unmount 14 | } 15 | -------------------------------------------------------------------------------- /apps/linearlite/src/lib/livestore/events.ts: -------------------------------------------------------------------------------- 1 | import { Priority } from "@/types/priority"; 2 | import { Status } from "@/types/status"; 3 | import { Events, Schema } from "@livestore/livestore"; 4 | 5 | export const createIssueWithDescription = Events.synced({ 6 | name: "v1.CreateIssueWithDescription", 7 | schema: Schema.Struct({ 8 | id: Schema.Number, 9 | title: Schema.String, 10 | priority: Priority, 11 | status: Status, 12 | created: Schema.DateFromNumber, 13 | modified: Schema.DateFromNumber, 14 | kanbanorder: Schema.String, 15 | description: Schema.String, 16 | creator: Schema.String 17 | }) 18 | }); 19 | 20 | export const createComment = Events.synced({ 21 | name: "v1.CreateComment", 22 | schema: Schema.Struct({ 23 | id: Schema.String, 24 | body: Schema.String, 25 | issueId: Schema.Number, 26 | created: Schema.DateFromNumber, 27 | creator: Schema.String 28 | }) 29 | }); 30 | 31 | export const deleteIssue = Events.synced({ 32 | name: "v1.DeleteIssue", 33 | schema: Schema.Struct({ id: Schema.Number, deleted: Schema.DateFromNumber }) 34 | }); 35 | 36 | export const deleteDescription = Events.synced({ 37 | name: "v1.DeleteDescription", 38 | schema: Schema.Struct({ id: Schema.Number, deleted: Schema.DateFromNumber }) 39 | }); 40 | 41 | export const deleteComment = Events.synced({ 42 | name: "v1.DeleteComment", 43 | schema: Schema.Struct({ id: Schema.String, deleted: Schema.DateFromNumber }) 44 | }); 45 | 46 | export const deleteCommentsByIssueId = Events.synced({ 47 | name: "v1.DeleteCommentsByIssueId", 48 | schema: Schema.Struct({ 49 | issueId: Schema.Number, 50 | deleted: Schema.DateFromNumber 51 | }) 52 | }); 53 | 54 | export const updateIssue = Events.synced({ 55 | name: "v1.UpdateIssue", 56 | schema: Schema.Struct({ 57 | id: Schema.Number, 58 | title: Schema.String, 59 | priority: Priority, 60 | status: Status, 61 | modified: Schema.DateFromNumber 62 | }) 63 | }); 64 | 65 | export const updateIssueStatus = Events.synced({ 66 | name: "v1.UpdateIssueStatus", 67 | schema: Schema.Struct({ 68 | id: Schema.Number, 69 | status: Status, 70 | modified: Schema.DateFromNumber 71 | }) 72 | }); 73 | 74 | export const updateIssueKanbanOrder = Events.synced({ 75 | name: "v1.UpdateIssueKanbanOrder", 76 | schema: Schema.Struct({ 77 | id: Schema.Number, 78 | status: Status, 79 | kanbanorder: Schema.String, 80 | modified: Schema.DateFromNumber 81 | }) 82 | }); 83 | 84 | export const updateIssueTitle = Events.synced({ 85 | name: "v1.UpdateIssueTitle", 86 | schema: Schema.Struct({ 87 | id: Schema.Number, 88 | title: Schema.String, 89 | modified: Schema.DateFromNumber 90 | }) 91 | }); 92 | 93 | export const moveIssue = Events.synced({ 94 | name: "v1.MoveIssue", 95 | schema: Schema.Struct({ 96 | id: Schema.Number, 97 | kanbanorder: Schema.String, 98 | status: Status, 99 | modified: Schema.DateFromNumber 100 | }) 101 | }); 102 | 103 | export const updateIssuePriority = Events.synced({ 104 | name: "v1.UpdateIssuePriority", 105 | schema: Schema.Struct({ 106 | id: Schema.Number, 107 | priority: Priority, 108 | modified: Schema.DateFromNumber 109 | }) 110 | }); 111 | 112 | export const updateDescription = Events.synced({ 113 | name: "v1.UpdateDescription", 114 | schema: Schema.Struct({ id: Schema.Number, body: Schema.String }) 115 | }); 116 | -------------------------------------------------------------------------------- /apps/linearlite/src/lib/livestore/queries.ts: -------------------------------------------------------------------------------- 1 | import { tables } from "@/lib/livestore/schema"; 2 | import { queryDb } from "@livestore/livestore"; 3 | import { useClientDocument } from "@livestore/react"; 4 | import React from "react"; 5 | 6 | export const useFilterState = () => useClientDocument(tables.filterState); 7 | 8 | export const useDebouncedScrollState = ( 9 | id: string, 10 | { debounce = 100 }: { debounce?: number } = {} 11 | ) => { 12 | const [initialState, setPersistedState] = useClientDocument( 13 | tables.scrollState, 14 | id 15 | ); 16 | const [state, setReactState] = React.useState(initialState); 17 | 18 | const debounceTimeoutRef = React.useRef(null); 19 | 20 | const setState = React.useCallback( 21 | (state: typeof initialState) => { 22 | if (debounceTimeoutRef.current) { 23 | clearTimeout(debounceTimeoutRef.current); 24 | } 25 | 26 | debounceTimeoutRef.current = setTimeout(() => { 27 | setPersistedState(state); 28 | setReactState(state); 29 | }, debounce); 30 | }, 31 | [setPersistedState, debounce] 32 | ); 33 | 34 | return [state, setState] as const; 35 | }; 36 | 37 | export const useFrontendState = () => useClientDocument(tables.frontendState); 38 | 39 | export const issueCount$ = queryDb( 40 | tables.issue.count().where({ deleted: null }), 41 | { label: "global.issueCount" } 42 | ); 43 | export const highestIssueId$ = queryDb( 44 | tables.issue 45 | .select("id") 46 | .orderBy("id", "desc") 47 | .first({ fallback: () => 0 }), 48 | { 49 | label: "global.highestIssueId" 50 | } 51 | ); 52 | export const highestKanbanOrder$ = queryDb( 53 | tables.issue 54 | .select("kanbanorder") 55 | .orderBy("kanbanorder", "desc") 56 | .first({ fallback: () => "a1" }), 57 | { 58 | label: "global.highestKanbanOrder" 59 | } 60 | ); 61 | export const filterState$ = queryDb(tables.filterState.get(), { 62 | label: "global.filterState" 63 | }); 64 | -------------------------------------------------------------------------------- /apps/linearlite/src/lib/livestore/schema/comment.ts: -------------------------------------------------------------------------------- 1 | import { State, Schema } from "@livestore/livestore"; 2 | 3 | export const comment = State.SQLite.table({ 4 | name: "comment", 5 | columns: { 6 | id: State.SQLite.text({ primaryKey: true }), 7 | body: State.SQLite.text({ default: "" }), 8 | creator: State.SQLite.text({ default: "" }), 9 | issueId: State.SQLite.integer(), 10 | created: State.SQLite.integer({ schema: Schema.DateFromNumber }), 11 | deleted: State.SQLite.integer({ 12 | nullable: true, 13 | schema: Schema.DateFromNumber 14 | }) 15 | }, 16 | indexes: [{ name: "issue_id", columns: ["issueId"] }] 17 | }); 18 | 19 | export type Comment = typeof comment.Type; 20 | -------------------------------------------------------------------------------- /apps/linearlite/src/lib/livestore/schema/description.ts: -------------------------------------------------------------------------------- 1 | import { State, Schema } from "@livestore/livestore"; 2 | 3 | export const description = State.SQLite.table({ 4 | name: "description", 5 | columns: { 6 | // TODO: id is also a foreign key to issue 7 | id: State.SQLite.integer({ primaryKey: true }), 8 | body: State.SQLite.text({ default: "" }), 9 | deleted: State.SQLite.integer({ 10 | nullable: true, 11 | schema: Schema.DateFromNumber 12 | }) 13 | } 14 | }); 15 | 16 | export type Description = typeof description.Type; 17 | -------------------------------------------------------------------------------- /apps/linearlite/src/lib/livestore/schema/filter-state.ts: -------------------------------------------------------------------------------- 1 | import { Priority } from "@/types/priority"; 2 | import { Status } from "@/types/status"; 3 | import { State, Schema, SessionIdSymbol } from "@livestore/livestore"; 4 | 5 | const OrderDirection = Schema.Literal("asc", "desc").annotations({ 6 | title: "OrderDirection" 7 | }); 8 | export type OrderDirection = typeof OrderDirection.Type; 9 | 10 | const OrderBy = Schema.Literal( 11 | "priority", 12 | "status", 13 | "created", 14 | "modified" 15 | ).annotations({ title: "OrderBy" }); 16 | export type OrderBy = typeof OrderBy.Type; 17 | 18 | export const FilterState = Schema.Struct({ 19 | orderBy: OrderBy, 20 | orderDirection: OrderDirection, 21 | status: Schema.NullOr(Schema.Array(Status)), 22 | priority: Schema.NullOr(Schema.Array(Priority)), 23 | query: Schema.NullOr(Schema.String) 24 | }).annotations({ title: "FilterState" }); 25 | export type FilterState = typeof FilterState.Type; 26 | 27 | export const filterState = State.SQLite.clientDocument({ 28 | name: "filter_state", 29 | schema: FilterState, 30 | default: { 31 | value: { 32 | orderBy: "created", 33 | orderDirection: "desc", 34 | priority: null, 35 | query: null, 36 | status: null 37 | }, 38 | id: SessionIdSymbol 39 | } 40 | }); 41 | -------------------------------------------------------------------------------- /apps/linearlite/src/lib/livestore/schema/frontend-state.ts: -------------------------------------------------------------------------------- 1 | import { State, Schema, SessionIdSymbol } from "@livestore/livestore"; 2 | 3 | const Theme = Schema.Literal("dark", "light", "system").annotations({ 4 | title: "Theme" 5 | }); 6 | export type Theme = typeof Theme.Type; 7 | 8 | export const FrontendState = Schema.Struct({ 9 | theme: Theme, 10 | user: Schema.String, 11 | showToolbar: Schema.Boolean 12 | }); 13 | export type FrontendState = typeof FrontendState.Type; 14 | 15 | export const defaultFrontendState: FrontendState = { 16 | theme: "system", 17 | user: "John Doe", 18 | showToolbar: true 19 | }; 20 | 21 | export const frontendState = State.SQLite.clientDocument({ 22 | name: "frontend_state", 23 | schema: FrontendState, 24 | default: { value: defaultFrontendState, id: SessionIdSymbol } 25 | }); 26 | -------------------------------------------------------------------------------- /apps/linearlite/src/lib/livestore/schema/index.ts: -------------------------------------------------------------------------------- 1 | import * as eventsDefs from "@/lib/livestore/events"; 2 | import { comment, type Comment } from "@/lib/livestore/schema/comment"; 3 | import { 4 | description, 5 | type Description 6 | } from "@/lib/livestore/schema/description"; 7 | import { 8 | filterState, 9 | type FilterState 10 | } from "@/lib/livestore/schema/filter-state"; 11 | import { 12 | frontendState, 13 | type FrontendState 14 | } from "@/lib/livestore/schema/frontend-state"; 15 | import { issue, type Issue } from "@/lib/livestore/schema/issue"; 16 | import { makeSchema, State } from "@livestore/livestore"; 17 | import { scrollState, type ScrollState } from "./scroll-state"; 18 | 19 | export { 20 | comment, 21 | description, 22 | filterState, 23 | frontendState, 24 | issue, 25 | scrollState, 26 | type Comment, 27 | type Description, 28 | type FilterState, 29 | type FrontendState, 30 | type Issue, 31 | type ScrollState 32 | }; 33 | 34 | export const events = { 35 | ...eventsDefs, 36 | frontendStateSet: frontendState.set, 37 | filterStateSet: filterState.set, 38 | scrollStateSet: scrollState.set 39 | }; 40 | 41 | export const tables = { 42 | issue, 43 | description, 44 | comment, 45 | filterState, 46 | frontendState, 47 | scrollState 48 | }; 49 | 50 | const materializers = State.SQLite.materializers(events, { 51 | "v1.CreateIssueWithDescription": (data) => [ 52 | tables.issue.insert({ 53 | id: data.id, 54 | title: data.title, 55 | priority: data.priority, 56 | status: data.status, 57 | created: data.created, 58 | modified: data.modified, 59 | kanbanorder: data.kanbanorder, 60 | creator: data.creator 61 | }), 62 | tables.description.insert({ id: data.id, body: data.description }) 63 | ], 64 | "v1.CreateComment": (data) => 65 | tables.comment.insert({ 66 | id: data.id, 67 | body: data.body, 68 | issueId: data.issueId, 69 | created: data.created, 70 | creator: data.creator 71 | }), 72 | "v1.DeleteIssue": (data) => 73 | tables.issue.update({ deleted: data.deleted }).where({ id: data.id }), 74 | "v1.DeleteDescription": (data) => 75 | tables.description.update({ deleted: data.deleted }).where({ id: data.id }), 76 | "v1.DeleteComment": (data) => 77 | tables.comment.update({ deleted: data.deleted }).where({ id: data.id }), 78 | "v1.DeleteCommentsByIssueId": (data) => 79 | tables.comment 80 | .update({ deleted: data.deleted }) 81 | .where({ issueId: data.issueId }), 82 | "v1.UpdateIssue": (data) => 83 | tables.issue 84 | .update({ 85 | title: data.title, 86 | priority: data.priority, 87 | status: data.status, 88 | modified: data.modified 89 | }) 90 | .where({ 91 | id: data.id 92 | }), 93 | "v1.UpdateIssueStatus": ({ id, status, modified }) => 94 | tables.issue.update({ status, modified }).where({ id }), 95 | "v1.UpdateIssueKanbanOrder": ({ id, status, kanbanorder, modified }) => 96 | tables.issue.update({ status, kanbanorder, modified }).where({ id }), 97 | "v1.UpdateIssueTitle": ({ id, title, modified }) => 98 | tables.issue.update({ title, modified }).where({ id }), 99 | "v1.MoveIssue": ({ id, kanbanorder, status, modified }) => 100 | tables.issue.update({ kanbanorder, status, modified }).where({ id }), 101 | "v1.UpdateIssuePriority": ({ id, priority, modified }) => 102 | tables.issue.update({ priority, modified }).where({ id }), 103 | "v1.UpdateDescription": ({ id, body }) => 104 | tables.description.update({ body }).where({ id }) 105 | }); 106 | 107 | const state = State.SQLite.makeState({ tables, materializers }); 108 | 109 | export const schema = makeSchema({ events, state }); 110 | -------------------------------------------------------------------------------- /apps/linearlite/src/lib/livestore/schema/issue.ts: -------------------------------------------------------------------------------- 1 | import { Priority } from "@/types/priority"; 2 | import { Status } from "@/types/status"; 3 | import { State, Schema } from "@livestore/livestore"; 4 | 5 | export const issue = State.SQLite.table({ 6 | name: "issue", 7 | columns: { 8 | id: State.SQLite.integer({ primaryKey: true }), 9 | title: State.SQLite.text({ default: "" }), 10 | creator: State.SQLite.text({ default: "" }), 11 | priority: State.SQLite.integer({ schema: Priority, default: 0 }), 12 | status: State.SQLite.integer({ schema: Status, default: 0 }), 13 | created: State.SQLite.integer({ schema: Schema.DateFromNumber }), 14 | deleted: State.SQLite.integer({ 15 | nullable: true, 16 | schema: Schema.DateFromNumber 17 | }), 18 | modified: State.SQLite.integer({ schema: Schema.DateFromNumber }), 19 | kanbanorder: State.SQLite.text({ nullable: false, default: "" }) 20 | }, 21 | indexes: [ 22 | { name: "issue_kanbanorder", columns: ["kanbanorder"] }, 23 | { name: "issue_created", columns: ["created"] } 24 | ], 25 | deriveEvents: true 26 | }); 27 | 28 | export type Issue = typeof issue.Type; 29 | -------------------------------------------------------------------------------- /apps/linearlite/src/lib/livestore/schema/scroll-state.ts: -------------------------------------------------------------------------------- 1 | import { State, Schema } from "@livestore/livestore"; 2 | 3 | export const ScrollState = Schema.Struct({ 4 | list: Schema.Number, 5 | backlog: Schema.optional(Schema.Number), 6 | todo: Schema.optional(Schema.Number), 7 | in_progress: Schema.optional(Schema.Number), 8 | done: Schema.optional(Schema.Number), 9 | canceled: Schema.optional(Schema.Number) 10 | }); 11 | 12 | export type ScrollState = typeof ScrollState.Type; 13 | 14 | export const scrollState = State.SQLite.clientDocument({ 15 | name: "scroll_state", 16 | schema: ScrollState, 17 | default: { value: { list: 0 } } 18 | }); 19 | -------------------------------------------------------------------------------- /apps/linearlite/src/lib/livestore/seed.ts: -------------------------------------------------------------------------------- 1 | import { type Store } from "@livestore/livestore"; 2 | 3 | import { priorityOptions } from "@/data/priority-options"; 4 | import { statusOptions } from "@/data/status-options"; 5 | import { events } from "@/lib/livestore/schema"; 6 | import { Priority } from "@/types/priority"; 7 | import { Status } from "@/types/status"; 8 | import { generateKeyBetween } from "fractional-indexing"; 9 | import { highestIssueId$, highestKanbanOrder$, issueCount$ } from "./queries"; 10 | 11 | export const seed = (store: Store, count: number) => { 12 | try { 13 | const existingCount = store.query(issueCount$); 14 | const highestId = store.query(highestIssueId$); 15 | const highestKanbanOrder = store.query(highestKanbanOrder$); 16 | if (existingCount >= count) return; 17 | count -= existingCount; 18 | console.log("SEEDING WITH ", count, " ADDITIONAL ROWS"); 19 | store.commit( 20 | ...Array.from(createIssues(count, highestId, highestKanbanOrder)) 21 | ); 22 | } finally { 23 | const url = new URL(window.location.href); 24 | url.searchParams.delete("seed"); 25 | window.history.replaceState({}, "", url.toString()); 26 | } 27 | }; 28 | 29 | function* createIssues( 30 | numTasks: number, 31 | highestId?: number, 32 | highestKanbanOrder?: string 33 | ): Generator { 34 | if (!highestId) highestId = 0; 35 | let kanbanorder = highestKanbanOrder ?? "a1"; 36 | 37 | const getRandomItem = (items: T[]) => 38 | items[Math.floor(Math.random() * items.length)]!; 39 | const generateText = () => { 40 | const action = getRandomItem(actionPhrases); 41 | const feature = getRandomItem(featurePhrases); 42 | const purpose = getRandomItem(purposePhrases); 43 | const context = getRandomItem(contextPhrases); 44 | return [ 45 | `${action} ${feature}`, 46 | `${action} ${feature} ${purpose}. ${context}.` 47 | ] as const; 48 | }; 49 | 50 | const now = Date.now(); 51 | const ONE_DAY = 24 * 60 * 60 * 1000; 52 | for (let i = 0; i < numTasks; i++) { 53 | kanbanorder = generateKeyBetween(kanbanorder, undefined); 54 | const [title, description] = generateText(); 55 | const issue = events.createIssueWithDescription({ 56 | id: (highestId += 1), 57 | creator: "John Doe", 58 | title, 59 | created: new Date(now - (numTasks - i) * 5 * ONE_DAY), 60 | modified: new Date(now - (numTasks - i) * 2 * ONE_DAY), 61 | status: getRandomItem(statuses), 62 | priority: getRandomItem(priorities), 63 | kanbanorder, 64 | description 65 | }); 66 | yield issue; 67 | } 68 | } 69 | 70 | export const priorities = priorityOptions.map( 71 | (_, index) => index 72 | ) as Priority[]; 73 | export const statuses = statusOptions.map((_, index) => index) as Status[]; 74 | const actionPhrases = [ 75 | "Implement", 76 | "Develop", 77 | "Design", 78 | "Test", 79 | "Review", 80 | "Refactor", 81 | "Redesign", 82 | "Enhance", 83 | "Optimize", 84 | "Fix", 85 | "Remove", 86 | "Mock", 87 | "Update", 88 | "Document", 89 | "Deploy", 90 | "Revert", 91 | "Add", 92 | "Destroy" 93 | ]; 94 | const featurePhrases = [ 95 | "the login mechanism", 96 | "the user dashboard", 97 | "the settings page", 98 | "database queries", 99 | "UI/UX components", 100 | "API endpoints", 101 | "the checkout process", 102 | "responsive layouts", 103 | "error handling logic", 104 | "the navigation menu", 105 | "the search functionality", 106 | "the onboarding flow", 107 | "the user profile page", 108 | "the admin dashboard", 109 | "the billing system", 110 | "the payment gateway", 111 | "the user permissions", 112 | "the user roles", 113 | "the user management" 114 | ]; 115 | const purposePhrases = [ 116 | "to improve user experience", 117 | "to speed up load times", 118 | "to enhance security", 119 | "to prepare for the next release", 120 | "following the latest design mockups", 121 | "to address reported issues", 122 | "for better mobile responsiveness", 123 | "to comply with new regulations", 124 | "to reflect customer feedback", 125 | "to keep up with platform changes", 126 | "to improve overall performance", 127 | "to fix a critical bug", 128 | "to add a new feature", 129 | "to remove deprecated code", 130 | "to improve code readability", 131 | "to fix a security vulnerability", 132 | "to improve SEO", 133 | "to improve accessibility", 134 | "to improve the codebase" 135 | ]; 136 | const contextPhrases = [ 137 | "Based on the latest UX research", 138 | "To ensure seamless user experience", 139 | "To cater to increasing user demands", 140 | "Keeping scalability in mind", 141 | "As outlined in the last meeting", 142 | "Following the latest design specifications", 143 | "To adhere to the updated requirements", 144 | "While ensuring backward compatibility", 145 | "To improve overall performance", 146 | "And ensure proper error feedback to the user" 147 | ]; 148 | -------------------------------------------------------------------------------- /apps/linearlite/src/lib/livestore/utils.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from "@/components/icons"; 2 | import { BootStatus, QueryBuilder } from "@livestore/livestore"; 3 | import React from "react"; 4 | import { FilterState, tables } from "./schema"; 5 | 6 | export const renderBootStatus = (bootStatus: BootStatus) => { 7 | return ( 8 |
9 |
10 | 11 | LiveStore 12 |
13 | {bootStatus.stage === "loading" &&
Loading...
} 14 | {bootStatus.stage === "migrating" && ( 15 |
16 | Migrating tables ({bootStatus.progress.done}/ 17 | {bootStatus.progress.total}) 18 |
19 | )} 20 | {bootStatus.stage === "rehydrating" && ( 21 |
22 | Rehydrating state ({bootStatus.progress.done}/ 23 | {bootStatus.progress.total}) 24 |
25 | )} 26 | {bootStatus.stage === "syncing" && ( 27 |
28 | Syncing state ({bootStatus.progress.done}/{bootStatus.progress.total}) 29 |
30 | )} 31 | {bootStatus.stage === "done" &&
Ready
} 32 |
33 | ); 34 | }; 35 | 36 | export const filterStateToWhere = (filterState: FilterState) => { 37 | const { status, priority, query } = filterState; 38 | 39 | return { 40 | status: status ? { op: "IN", value: status } : undefined, 41 | priority: priority ? { op: "IN", value: priority } : undefined, 42 | // TODO treat query as `OR` in 43 | title: query ? { op: "LIKE", value: `%${query}%` } : undefined 44 | } satisfies QueryBuilder.WhereParams; 45 | }; 46 | 47 | export const filterStateToOrderBy = (filterState: FilterState) => [ 48 | { col: filterState.orderBy, direction: filterState.orderDirection } 49 | ]; 50 | -------------------------------------------------------------------------------- /apps/linearlite/src/lib/livestore/worker.ts: -------------------------------------------------------------------------------- 1 | import { schema } from "@/lib/livestore/schema"; 2 | import { makeWorker } from "@livestore/adapter-web/worker"; 3 | import { makeCfSync } from "@livestore/sync-cf"; 4 | 5 | makeWorker({ 6 | schema, 7 | sync: { 8 | backend: makeCfSync({ 9 | url: import.meta.env.VITE_LIVESTORE_SYNC_URL as string 10 | }), 11 | initialSyncOptions: { _tag: "Blocking", timeout: 5000 } 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /apps/linearlite/src/server.ts: -------------------------------------------------------------------------------- 1 | import { makeDurableObject, makeWorker } from "@livestore/sync-cf/cf-worker"; 2 | 3 | export class WebSocketServer extends makeDurableObject({ 4 | onPush: async (message) => { 5 | console.log("onPush", message.batch); 6 | }, 7 | onPull: async (message) => { 8 | console.log("onPull", message); 9 | } 10 | }) {} 11 | 12 | export default makeWorker({ 13 | validatePayload: (payload: any) => { 14 | if (payload?.authToken !== "insecure-token-change-me") { 15 | throw new Error("Invalid auth token"); 16 | } 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /apps/linearlite/src/types/comment.ts: -------------------------------------------------------------------------------- 1 | export type Comment = { 2 | id: string; 3 | body: string; 4 | creator: string; 5 | issueId: string; 6 | created: number; 7 | }; 8 | -------------------------------------------------------------------------------- /apps/linearlite/src/types/description.ts: -------------------------------------------------------------------------------- 1 | export type Description = { 2 | id: string; 3 | body: string; 4 | }; 5 | -------------------------------------------------------------------------------- /apps/linearlite/src/types/issue.ts: -------------------------------------------------------------------------------- 1 | import { Priority } from "@/types/priority"; 2 | import { Status } from "@/types/status"; 3 | 4 | export type Issue = { 5 | id: number; 6 | title: string; 7 | creator: string; 8 | priority: Priority; 9 | status: Status; 10 | created: Date; 11 | modified: Date; 12 | kanbanorder: string; 13 | }; 14 | -------------------------------------------------------------------------------- /apps/linearlite/src/types/priority.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "@livestore/livestore"; 2 | 3 | export const Priority = Schema.Literal(0, 1, 2, 3, 4).annotations({ 4 | title: "Priority" 5 | }); 6 | 7 | export type Priority = typeof Priority.Type; 8 | -------------------------------------------------------------------------------- /apps/linearlite/src/types/status.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "@livestore/livestore"; 2 | 3 | export const Status = Schema.Literal(0, 1, 2, 3, 4).annotations({ 4 | title: "Status" 5 | }); 6 | 7 | export type Status = typeof Status.Type; 8 | -------------------------------------------------------------------------------- /apps/linearlite/src/utils/format-date.ts: -------------------------------------------------------------------------------- 1 | export const formatDate = (date?: Date): string => { 2 | if (!date) return ""; 3 | 4 | // Get the day of the month (without any leading zero) 5 | const day = date.getDate(); 6 | 7 | // Get the abbreviated month name (e.g., "Jan", "Feb", etc.) 8 | const month = date.toLocaleString("default", { month: "short" }); 9 | 10 | return `${day} ${month}`; 11 | }; 12 | -------------------------------------------------------------------------------- /apps/linearlite/src/utils/get-acronym.ts: -------------------------------------------------------------------------------- 1 | export const getAcronym = (name: string) => { 2 | let acronym = ((name || "").match(/\b(\w)/g) || []) 3 | .join("") 4 | .slice(0, 2) 5 | .toUpperCase(); 6 | if (acronym.length === 1) { 7 | acronym = acronym + name.slice(1, 2).toLowerCase(); 8 | } 9 | return acronym; 10 | }; 11 | -------------------------------------------------------------------------------- /apps/linearlite/src/utils/get-issue-tag.ts: -------------------------------------------------------------------------------- 1 | export const getIssueTag = (issueId: number) => { 2 | return `ISS-${issueId}`; 3 | }; 4 | -------------------------------------------------------------------------------- /apps/linearlite/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"], 4 | darkMode: "selector", 5 | theme: { 6 | extend: { 7 | fontFamily: { 8 | sans: [ 9 | "Inter\\ UI", 10 | "SF\\ Pro\\ Display", 11 | "-apple-system", 12 | "BlinkMacSystemFont", 13 | "Segoe\\ UI", 14 | "Roboto", 15 | "Oxygen", 16 | "Ubuntu", 17 | "Cantarell", 18 | "Open\\ Sans", 19 | "Helvetica\\ Neue", 20 | "sans-serif" 21 | ] 22 | }, 23 | fontSize: { 24 | "2xs": "0.625rem" 25 | } 26 | } 27 | }, 28 | 29 | variants: { 30 | extend: { 31 | backgroundColor: ["checked"], 32 | borderColor: ["checked"] 33 | } 34 | }, 35 | plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")] 36 | }; 37 | -------------------------------------------------------------------------------- /apps/linearlite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "useDefineForClassFields": true, 5 | "paths": { 6 | "@/*": ["./src/*"] 7 | }, 8 | "types": [ 9 | "@cloudflare/workers-types", 10 | "vite/client", 11 | "node", 12 | "vite-plugin-svgr/client" 13 | ] 14 | }, 15 | "include": ["src"] 16 | } 17 | -------------------------------------------------------------------------------- /apps/linearlite/vite.config.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import path from "node:path"; 4 | import process from "node:process"; 5 | 6 | import { livestoreDevtoolsPlugin } from "@livestore/devtools-vite"; 7 | import react from "@vitejs/plugin-react"; 8 | import { defineConfig } from "vite"; 9 | import svgr from "vite-plugin-svgr"; 10 | import tailwindcss from "@tailwindcss/vite"; 11 | import { cloudflare } from "@cloudflare/vite-plugin"; 12 | import devtoolsJson from "vite-plugin-devtools-json"; 13 | 14 | const isProdBuild = process.env.NODE_ENV === "production"; 15 | 16 | // https://vitejs.dev/config/ 17 | export default defineConfig({ 18 | worker: isProdBuild ? { format: "es" } : undefined, 19 | optimizeDeps: { 20 | // TODO remove once fixed https://github.com/vitejs/vite/issues/8427 21 | exclude: ["@livestore/wa-sqlite"] 22 | }, 23 | resolve: { 24 | alias: { 25 | "@": path.resolve(__dirname, "src") 26 | } 27 | }, 28 | plugins: [ 29 | devtoolsJson(), 30 | react(), 31 | cloudflare(), 32 | tailwindcss(), 33 | livestoreDevtoolsPlugin({ 34 | schemaPath: "./src/lib/livestore/schema/index.ts" 35 | }), 36 | svgr({ 37 | svgrOptions: { 38 | svgo: true, 39 | plugins: ["@svgr/plugin-svgo", "@svgr/plugin-jsx"], 40 | svgoConfig: { 41 | plugins: [ 42 | "preset-default", 43 | "removeTitle", 44 | "removeDesc", 45 | "removeDoctype", 46 | "cleanupIds" 47 | ] 48 | } 49 | } 50 | }) 51 | ] 52 | }); 53 | -------------------------------------------------------------------------------- /apps/linearlite/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "name": "livestore-linearlite", 3 | "main": "./src/server.ts", 4 | "compatibility_date": "2025-05-08", 5 | "compatibility_flags": ["nodejs_compat"], 6 | "assets": { 7 | "directory": "public" 8 | }, 9 | "durable_objects": { 10 | "bindings": [ 11 | { 12 | "name": "WEBSOCKET_SERVER", 13 | "class_name": "WebSocketServer" 14 | } 15 | ] 16 | }, 17 | "migrations": [ 18 | { 19 | "tag": "v1", 20 | "new_sqlite_classes": ["WebSocketServer"] 21 | } 22 | ], 23 | // "d1_databases": [ 24 | // { 25 | // "binding": "DB", 26 | // "database_name": "livestore-linearlite", 27 | // "database_id": "1c9b5dae-f1fa-49d8-83fa-7bd5b39c4121" 28 | // // "database_id": "${LIVESTORE_CF_SYNC_DATABASE_ID}" 29 | // } 30 | // ], 31 | 32 | "vars": { 33 | // should be set via CF dashboard (as secret) 34 | // "ADMIN_SECRET": "..." 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /apps/react-router/.react-router/types/+future.ts: -------------------------------------------------------------------------------- 1 | // Generated by React Router 2 | 3 | import "react-router"; 4 | 5 | declare module "react-router" { 6 | interface Future { 7 | unstable_middleware: false 8 | } 9 | } -------------------------------------------------------------------------------- /apps/react-router/.react-router/types/+routes.ts: -------------------------------------------------------------------------------- 1 | // Generated by React Router 2 | 3 | import "react-router" 4 | 5 | declare module "react-router" { 6 | interface Register { 7 | pages: Pages 8 | routeFiles: RouteFiles 9 | } 10 | } 11 | 12 | type Pages = { 13 | "/": { 14 | params: {}; 15 | }; 16 | }; 17 | 18 | type RouteFiles = { 19 | "root.tsx": { 20 | id: "root"; 21 | page: "/"; 22 | }; 23 | "routes/home.tsx": { 24 | id: "routes/home"; 25 | page: "/"; 26 | }; 27 | }; -------------------------------------------------------------------------------- /apps/react-router/.react-router/types/+server-build.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by React Router 2 | 3 | declare module "virtual:react-router/server-build" { 4 | import { ServerBuild } from "react-router"; 5 | export const assets: ServerBuild["assets"]; 6 | export const assetsBuildDirectory: ServerBuild["assetsBuildDirectory"]; 7 | export const basename: ServerBuild["basename"]; 8 | export const entry: ServerBuild["entry"]; 9 | export const future: ServerBuild["future"]; 10 | export const isSpaMode: ServerBuild["isSpaMode"]; 11 | export const prerender: ServerBuild["prerender"]; 12 | export const publicPath: ServerBuild["publicPath"]; 13 | export const routeDiscovery: ServerBuild["routeDiscovery"]; 14 | export const routes: ServerBuild["routes"]; 15 | export const ssr: ServerBuild["ssr"]; 16 | export const unstable_getCriticalCss: ServerBuild["unstable_getCriticalCss"]; 17 | } -------------------------------------------------------------------------------- /apps/react-router/.react-router/types/app/+types/root.ts: -------------------------------------------------------------------------------- 1 | // Generated by React Router 2 | 3 | import type { GetInfo, GetAnnotations } from "react-router/internal"; 4 | 5 | type Module = typeof import("../root.js") 6 | 7 | type Info = GetInfo<{ 8 | file: "root.tsx", 9 | module: Module 10 | }> 11 | 12 | type Matches = [{ 13 | id: "root"; 14 | module: typeof import("../root.js"); 15 | }]; 16 | 17 | type Annotations = GetAnnotations; 18 | 19 | export namespace Route { 20 | // links 21 | export type LinkDescriptors = Annotations["LinkDescriptors"]; 22 | export type LinksFunction = Annotations["LinksFunction"]; 23 | 24 | // meta 25 | export type MetaArgs = Annotations["MetaArgs"]; 26 | export type MetaDescriptors = Annotations["MetaDescriptors"]; 27 | export type MetaFunction = Annotations["MetaFunction"]; 28 | 29 | // headers 30 | export type HeadersArgs = Annotations["HeadersArgs"]; 31 | export type HeadersFunction = Annotations["HeadersFunction"]; 32 | 33 | // unstable_middleware 34 | export type unstable_MiddlewareFunction = Annotations["unstable_MiddlewareFunction"]; 35 | 36 | // unstable_clientMiddleware 37 | export type unstable_ClientMiddlewareFunction = Annotations["unstable_ClientMiddlewareFunction"]; 38 | 39 | // loader 40 | export type LoaderArgs = Annotations["LoaderArgs"]; 41 | 42 | // clientLoader 43 | export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; 44 | 45 | // action 46 | export type ActionArgs = Annotations["ActionArgs"]; 47 | 48 | // clientAction 49 | export type ClientActionArgs = Annotations["ClientActionArgs"]; 50 | 51 | // HydrateFallback 52 | export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; 53 | 54 | // Component 55 | export type ComponentProps = Annotations["ComponentProps"]; 56 | 57 | // ErrorBoundary 58 | export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; 59 | } -------------------------------------------------------------------------------- /apps/react-router/.react-router/types/app/routes/+types/home.ts: -------------------------------------------------------------------------------- 1 | // Generated by React Router 2 | 3 | import type { GetInfo, GetAnnotations } from "react-router/internal"; 4 | 5 | type Module = typeof import("../home.js") 6 | 7 | type Info = GetInfo<{ 8 | file: "routes/home.tsx", 9 | module: Module 10 | }> 11 | 12 | type Matches = [{ 13 | id: "root"; 14 | module: typeof import("../../root.js"); 15 | }, { 16 | id: "routes/home"; 17 | module: typeof import("../home.js"); 18 | }]; 19 | 20 | type Annotations = GetAnnotations; 21 | 22 | export namespace Route { 23 | // links 24 | export type LinkDescriptors = Annotations["LinkDescriptors"]; 25 | export type LinksFunction = Annotations["LinksFunction"]; 26 | 27 | // meta 28 | export type MetaArgs = Annotations["MetaArgs"]; 29 | export type MetaDescriptors = Annotations["MetaDescriptors"]; 30 | export type MetaFunction = Annotations["MetaFunction"]; 31 | 32 | // headers 33 | export type HeadersArgs = Annotations["HeadersArgs"]; 34 | export type HeadersFunction = Annotations["HeadersFunction"]; 35 | 36 | // unstable_middleware 37 | export type unstable_MiddlewareFunction = Annotations["unstable_MiddlewareFunction"]; 38 | 39 | // unstable_clientMiddleware 40 | export type unstable_ClientMiddlewareFunction = Annotations["unstable_ClientMiddlewareFunction"]; 41 | 42 | // loader 43 | export type LoaderArgs = Annotations["LoaderArgs"]; 44 | 45 | // clientLoader 46 | export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; 47 | 48 | // action 49 | export type ActionArgs = Annotations["ActionArgs"]; 50 | 51 | // clientAction 52 | export type ClientActionArgs = Annotations["ClientActionArgs"]; 53 | 54 | // HydrateFallback 55 | export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; 56 | 57 | // Component 58 | export type ComponentProps = Annotations["ComponentProps"]; 59 | 60 | // ErrorBoundary 61 | export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; 62 | } -------------------------------------------------------------------------------- /apps/react-router/README.md: -------------------------------------------------------------------------------- 1 | ## react-router 2 | 3 | ### dev 4 | 5 | `npm i && npm start` 6 | 7 | ### deploy 8 | 9 | `npm i && npm run deploy` 10 | 11 | (don't forget to change the value of `VITE_LIVESTORE_SYNC_URL` in `package.json`) 12 | -------------------------------------------------------------------------------- /apps/react-router/app/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss" source("."); 2 | 3 | @theme { 4 | --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, 5 | "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 6 | } 7 | 8 | html, 9 | body { 10 | @apply bg-white dark:bg-gray-950; 11 | 12 | @media (prefers-color-scheme: dark) { 13 | color-scheme: dark; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/react-router/app/counter.tsx: -------------------------------------------------------------------------------- 1 | import { useStore } from "@livestore/react"; 2 | import { queryDb } from "@livestore/livestore"; 3 | import { tables, events } from "./livestore/schema"; 4 | 5 | const counter$ = queryDb(tables.counter.where({ id: "main" }), { 6 | label: "counter" 7 | }); 8 | 9 | export function Counter() { 10 | const { store } = useStore(); 11 | const counter = store.useQuery(counter$)?.[0]?.value ?? 0; 12 | 13 | return ( 14 |
15 |
16 |

Counter: {counter}

17 |
18 | 26 | 34 |
35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /apps/react-router/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { AppLoadContext, EntryContext } from "react-router"; 2 | import { ServerRouter } from "react-router"; 3 | import { isbot } from "isbot"; 4 | import { renderToReadableStream } from "react-dom/server"; 5 | 6 | export default async function handleRequest( 7 | request: Request, 8 | responseStatusCode: number, 9 | responseHeaders: Headers, 10 | routerContext: EntryContext, 11 | _loadContext: AppLoadContext 12 | ) { 13 | let shellRendered = false; 14 | const userAgent = request.headers.get("user-agent"); 15 | 16 | const body = await renderToReadableStream( 17 | , 18 | { 19 | onError(error: unknown) { 20 | responseStatusCode = 500; 21 | // Log streaming rendering errors from inside the shell. Don't log 22 | // errors encountered during initial shell rendering since they'll 23 | // reject and get logged in handleDocumentRequest. 24 | if (shellRendered) { 25 | console.error(error); 26 | } 27 | }, 28 | } 29 | ); 30 | shellRendered = true; 31 | 32 | // Ensure requests from bots and SPA Mode renders wait for all content to load before responding 33 | // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation 34 | if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) { 35 | await body.allReady; 36 | } 37 | 38 | responseHeaders.set("Content-Type", "text/html"); 39 | return new Response(body, { 40 | headers: responseHeaders, 41 | status: responseStatusCode, 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /apps/react-router/app/livestore/livestore.worker.ts: -------------------------------------------------------------------------------- 1 | import { makeWorker } from "@livestore/adapter-web/worker"; 2 | import { makeCfSync } from "@livestore/sync-cf"; 3 | 4 | import { schema } from "./schema"; 5 | 6 | makeWorker({ 7 | schema, 8 | sync: { 9 | backend: makeCfSync({ url: import.meta.env.VITE_LIVESTORE_SYNC_URL }), 10 | initialSyncOptions: { _tag: "Blocking", timeout: 5000 } 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /apps/react-router/app/livestore/schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Events, 3 | makeSchema, 4 | Schema, 5 | SessionIdSymbol, 6 | State 7 | } from "@livestore/livestore"; 8 | 9 | // Define a simple counter table 10 | export const tables = { 11 | counter: State.SQLite.table({ 12 | name: "counter", 13 | columns: { 14 | id: State.SQLite.text({ primaryKey: true }), 15 | value: State.SQLite.integer({ default: 0 }) 16 | } 17 | }) 18 | }; 19 | 20 | // Events for incrementing and decrementing the counter 21 | export const events = { 22 | counterIncremented: Events.synced({ 23 | name: "v1.CounterIncremented", 24 | schema: Schema.Struct({ amount: Schema.Number }) 25 | }), 26 | counterDecremented: Events.synced({ 27 | name: "v1.CounterDecremented", 28 | schema: Schema.Struct({ amount: Schema.Number }) 29 | }) 30 | }; 31 | 32 | // Materializers to handle counter events 33 | const materializers = State.SQLite.materializers(events, { 34 | "v1.CounterIncremented": ({ amount }) => 35 | tables.counter 36 | .insert({ id: "main", value: amount }) 37 | .onConflict("id", "replace"), 38 | "v1.CounterDecremented": ({ amount }) => 39 | tables.counter 40 | .insert({ id: "main", value: amount }) 41 | .onConflict("id", "replace") 42 | }); 43 | 44 | const state = State.SQLite.makeState({ tables, materializers }); 45 | 46 | export const schema = makeSchema({ events, state }); 47 | -------------------------------------------------------------------------------- /apps/react-router/app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | isRouteErrorResponse, 3 | Links, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | } from "react-router"; 9 | import { makePersistedAdapter } from "@livestore/adapter-web"; 10 | import LiveStoreSharedWorker from "@livestore/adapter-web/shared-worker?sharedworker"; 11 | import { LiveStoreProvider } from "@livestore/react"; 12 | import type React from "react"; 13 | import { unstable_batchedUpdates as batchUpdates } from "react-dom"; 14 | 15 | import LiveStoreWorker from "./livestore/livestore.worker?worker"; 16 | import { schema } from "./livestore/schema"; 17 | 18 | import type { Route } from "./+types/root"; 19 | import "./app.css"; 20 | 21 | export const getStoreId = () => { 22 | if (typeof window === "undefined") return "unused"; 23 | 24 | const searchParams = new URLSearchParams(window.location.search); 25 | const storeId = searchParams.get("storeId"); 26 | if (storeId !== null) return storeId; 27 | 28 | const newAppId = crypto.randomUUID(); 29 | searchParams.set("storeId", newAppId); 30 | 31 | window.location.search = searchParams.toString(); 32 | }; 33 | 34 | export const links: Route.LinksFunction = () => [ 35 | { rel: "preconnect", href: "https://fonts.googleapis.com" }, 36 | { 37 | rel: "preconnect", 38 | href: "https://fonts.gstatic.com", 39 | crossOrigin: "anonymous", 40 | }, 41 | { 42 | rel: "stylesheet", 43 | href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", 44 | }, 45 | ]; 46 | 47 | // const storeId = getStoreId(); 48 | // let's just use a sample store id for now 49 | const storeId = "sample-store"; 50 | 51 | const adapter = makePersistedAdapter({ 52 | storage: { type: "opfs" }, 53 | worker: LiveStoreWorker, 54 | sharedWorker: LiveStoreSharedWorker, 55 | }); 56 | 57 | export function Layout({ children }: { children: React.ReactNode }) { 58 | return ( 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 |
Loading LiveStore ({_.stage})...
} 71 | batchUpdates={batchUpdates} 72 | storeId={storeId} 73 | syncPayload={{ authToken: "insecure-token-change-me" }} 74 | > 75 | {children} 76 | 77 |
78 | 79 | 80 | 81 | ); 82 | } 83 | 84 | export default function App() { 85 | return ; 86 | } 87 | 88 | export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { 89 | let message = "Oops!"; 90 | let details = "An unexpected error occurred."; 91 | let stack: string | undefined; 92 | 93 | if (isRouteErrorResponse(error)) { 94 | message = error.status === 404 ? "404" : "Error"; 95 | details = 96 | error.status === 404 97 | ? "The requested page could not be found." 98 | : error.statusText || details; 99 | } else if (import.meta.env.DEV && error && error instanceof Error) { 100 | details = error.message; 101 | stack = error.stack; 102 | } 103 | 104 | return ( 105 |
106 |

{message}

107 |

{details}

108 | {stack && ( 109 |
110 |           {stack}
111 |         
112 | )} 113 |
114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /apps/react-router/app/routes.ts: -------------------------------------------------------------------------------- 1 | import { type RouteConfig, index } from "@react-router/dev/routes"; 2 | 3 | export default [index("routes/home.tsx")] satisfies RouteConfig; 4 | -------------------------------------------------------------------------------- /apps/react-router/app/routes/home.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/home"; 2 | import { Counter } from "../counter"; 3 | 4 | export function meta({}: Route.MetaArgs) { 5 | return [ 6 | { title: "New React Router App" }, 7 | { name: "description", content: "Welcome to React Router!" } 8 | ]; 9 | } 10 | 11 | export function loader({ context }: Route.LoaderArgs) { 12 | return { message: context.cloudflare.env.VALUE_FROM_CLOUDFLARE }; 13 | } 14 | 15 | export default function Home({ loaderData }: Route.ComponentProps) { 16 | return ; 17 | } 18 | -------------------------------------------------------------------------------- /apps/react-router/app/server.ts: -------------------------------------------------------------------------------- 1 | import { createRequestHandler } from "react-router"; 2 | import { makeDurableObject, makeWorker } from "@livestore/sync-cf/cf-worker"; 3 | 4 | declare module "react-router" { 5 | export interface AppLoadContext { 6 | cloudflare: { 7 | env: Env; 8 | ctx: ExecutionContext; 9 | }; 10 | } 11 | } 12 | 13 | export interface Env { 14 | DB: D1Database; 15 | ADMIN_SECRET: string; 16 | WEBSOCKET_SERVER: DurableObjectNamespace; 17 | VALUE_FROM_CLOUDFLARE: string; 18 | } 19 | 20 | export class WebSocketServer extends makeDurableObject({ 21 | onPush: async (message) => { 22 | console.log("onPush", message.batch); 23 | }, 24 | onPull: async (message) => { 25 | console.log("onPull", message); 26 | } 27 | }) {} 28 | 29 | const requestHandler = createRequestHandler( 30 | // @ts-expect-error eh whatever 31 | () => import("virtual:react-router/server-build"), 32 | import.meta.env.MODE 33 | ); 34 | 35 | const livestoreWorker = makeWorker({ 36 | validatePayload: (payload: any) => { 37 | if (payload?.authToken !== "insecure-token-change-me") { 38 | throw new Error("Invalid auth token"); 39 | } 40 | } 41 | }); 42 | 43 | export default { 44 | async fetch(request, env, ctx) { 45 | const url = new URL(request.url); 46 | if (url.pathname.startsWith("/websocket")) { 47 | return livestoreWorker.fetch(request, env, ctx); 48 | } else if (url.pathname.startsWith("/_livestore")) { 49 | return new Response("Livestore devtools are disabled for this app", { 50 | status: 404 51 | }); 52 | } else { 53 | return requestHandler(request, { 54 | cloudflare: { env, ctx } 55 | }); 56 | } 57 | } 58 | } satisfies ExportedHandler; 59 | -------------------------------------------------------------------------------- /apps/react-router/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "livestore-react-router", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "typecheck": "react-router typegen && tsc -b", 6 | "build": "VITE_LIVESTORE_SYNC_URL=http://livestore-react-router.threepointone.workers.dev react-router build", 7 | "start": "VITE_LIVESTORE_SYNC_URL=http://localhost:5173 react-router dev", 8 | "deploy": "npm run build && wrangler deploy", 9 | "clean": "rm -rf node_modules/.vite .wrangler .react-router" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "type": "module", 15 | "dependencies": { 16 | "isbot": "^5.1.28", 17 | "react-router": "^7.6.1" 18 | }, 19 | "devDependencies": { 20 | "@react-router/dev": "^7.6.1", 21 | "@tailwindcss/vite": "^4.1.8", 22 | "tailwindcss": "^4.1.8", 23 | "vite-tsconfig-paths": "^5.1.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /apps/react-router/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threepointone/livestore-cf-examples/ebdae73b8481165ca0ad2310df2c07373b28b501/apps/react-router/public/favicon.ico -------------------------------------------------------------------------------- /apps/react-router/react-router.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@react-router/dev/config"; 2 | 3 | export default { 4 | ssr: true, 5 | future: { 6 | unstable_viteEnvironmentApi: true, 7 | }, 8 | } satisfies Config; 9 | -------------------------------------------------------------------------------- /apps/react-router/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "rootDirs": [".", "./.react-router/types"], 6 | "paths": { 7 | "~/*": ["./app/*"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /apps/react-router/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { reactRouter } from "@react-router/dev/vite"; 2 | import { cloudflare } from "@cloudflare/vite-plugin"; 3 | import tailwindcss from "@tailwindcss/vite"; 4 | import { defineConfig } from "vite"; 5 | import tsconfigPaths from "vite-tsconfig-paths"; 6 | // import { livestoreDevtoolsPlugin } from "@livestore/devtools-vite"; 7 | import devtoolsJson from "vite-plugin-devtools-json"; 8 | 9 | export default defineConfig({ 10 | optimizeDeps: { 11 | exclude: ["@livestore/wa-sqlite"] 12 | }, 13 | worker: { format: "es" }, 14 | plugins: [ 15 | devtoolsJson(), 16 | cloudflare({ viteEnvironment: { name: "ssr" } }), 17 | tailwindcss(), 18 | // this is causing the "invoke was called before connect" error 19 | // livestoreDevtoolsPlugin({ schemaPath: "./app/livestore/schema.ts" }), 20 | reactRouter(), 21 | tsconfigPaths() 22 | ] 23 | }); 24 | -------------------------------------------------------------------------------- /apps/react-router/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "name": "livestore-react-router", 3 | "main": "./app/server.ts", 4 | "compatibility_date": "2025-05-08", 5 | "compatibility_flags": ["nodejs_compat"], 6 | "durable_objects": { 7 | "bindings": [ 8 | { 9 | "name": "WEBSOCKET_SERVER", 10 | "class_name": "WebSocketServer" 11 | } 12 | ] 13 | }, 14 | "migrations": [ 15 | { 16 | "tag": "v1", 17 | "new_sqlite_classes": ["WebSocketServer"] 18 | } 19 | ], 20 | // "d1_databases": [ 21 | // { 22 | // "binding": "DB", 23 | // "database_name": "livestore-react-router", 24 | // "database_id": "1c9b5dae-f1fa-49d8-83fa-7bd5b39c4121" 25 | // // "database_id": "${LIVESTORE_CF_SYNC_DATABASE_ID}" 26 | // } 27 | // ], 28 | "vars": { 29 | "VALUE_FROM_CLOUDFLARE": "livestore ⨉ react-router", 30 | "VITE_LIVESTORE_SYNC_URL": "http://localhost:5173" // this gets replaced for prod builds 31 | // should be set via CF dashboard (as secret) 32 | // "ADMIN_SECRET": "..." 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /apps/todomvc/README.md: -------------------------------------------------------------------------------- 1 | ## todomvc 2 | 3 | ### dev 4 | 5 | `npm i && npm start` 6 | 7 | ### deploy 8 | 9 | `npm i && npm run deploy` 10 | 11 | (don't forget to change the value of `VITE_LIVESTORE_SYNC_URL` in `package.json`) 12 | -------------------------------------------------------------------------------- /apps/todomvc/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TodoMVC 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /apps/todomvc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todomvc", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "start": "VITE_LIVESTORE_SYNC_URL=http://localhost:5173 vite dev", 6 | "deploy": "VITE_LIVESTORE_SYNC_URL=https://livestore-todomvc.threepointone.workers.dev vite build && wrangler deploy", 7 | "clean": "rm -rf node_modules/.vite .wrangler" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "type": "module", 13 | "dependencies": { 14 | "todomvc-app-css": "^2.4.3" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/todomvc/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threepointone/livestore-cf-examples/ebdae73b8481165ca0ad2310df2c07373b28b501/apps/todomvc/public/favicon.ico -------------------------------------------------------------------------------- /apps/todomvc/src/app.tsx: -------------------------------------------------------------------------------- 1 | import { makePersistedAdapter } from "@livestore/adapter-web"; 2 | import LiveStoreSharedWorker from "@livestore/adapter-web/shared-worker?sharedworker"; 3 | import { LiveStoreProvider } from "@livestore/react"; 4 | import { FPSMeter } from "@overengineering/fps-meter"; 5 | import type React from "react"; 6 | import { unstable_batchedUpdates as batchUpdates } from "react-dom"; 7 | 8 | import { Footer } from "./components/Footer.js"; 9 | import { Header } from "./components/Header.js"; 10 | import { MainSection } from "./components/MainSection.js"; 11 | import LiveStoreWorker from "./livestore.worker?worker"; 12 | import { schema } from "./livestore/schema.js"; 13 | import { getStoreId } from "./util/store-id.js"; 14 | 15 | const AppBody: React.FC = () => ( 16 |
17 |
18 | 19 |
20 |
21 | ); 22 | 23 | const storeId = getStoreId(); 24 | 25 | const adapter = makePersistedAdapter({ 26 | storage: { type: "opfs" }, 27 | worker: LiveStoreWorker, 28 | sharedWorker: LiveStoreSharedWorker 29 | }); 30 | 31 | export const App: React.FC = () => ( 32 |
Loading LiveStore ({_.stage})...
} 36 | batchUpdates={batchUpdates} 37 | storeId={storeId} 38 | syncPayload={{ authToken: "insecure-token-change-me" }} 39 | > 40 |
41 | 42 |
43 | 44 |
45 | ); 46 | -------------------------------------------------------------------------------- /apps/todomvc/src/client.tsx: -------------------------------------------------------------------------------- 1 | import "todomvc-app-css/index.css"; 2 | 3 | import { createRoot } from "react-dom/client"; 4 | 5 | import { App } from "./app"; 6 | 7 | createRoot(document.getElementById("app")!).render(); 8 | 9 | // ReactDOM.createRoot(document.getElementById('react-app')!).render( 10 | // 11 | // 12 | // , 13 | // ) 14 | -------------------------------------------------------------------------------- /apps/todomvc/src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { queryDb } from "@livestore/livestore"; 2 | import { useStore } from "@livestore/react"; 3 | import React from "react"; 4 | 5 | import { uiState$ } from "../livestore/queries.js"; 6 | import { events, tables } from "../livestore/schema.js"; 7 | 8 | const incompleteCount$ = queryDb( 9 | tables.todos.count().where({ completed: false, deletedAt: null }), 10 | { 11 | label: "incompleteCount" 12 | } 13 | ); 14 | 15 | export const Footer: React.FC = () => { 16 | const { store } = useStore(); 17 | const { filter } = store.useQuery(uiState$); 18 | const incompleteCount = store.useQuery(incompleteCount$); 19 | const setFilter = (filter: (typeof tables.uiState.Value)["filter"]) => 20 | store.commit(events.uiStateSet({ filter })); 21 | 22 | return ( 23 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /apps/todomvc/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useStore } from "@livestore/react"; 2 | import React from "react"; 3 | 4 | import { uiState$ } from "../livestore/queries.js"; 5 | import { events } from "../livestore/schema.js"; 6 | 7 | export const Header: React.FC = () => { 8 | const { store } = useStore(); 9 | const { newTodoText } = store.useQuery(uiState$); 10 | 11 | const updatedNewTodoText = (text: string) => 12 | store.commit(events.uiStateSet({ newTodoText: text })); 13 | 14 | const todoCreated = () => 15 | store.commit( 16 | events.todoCreated({ id: crypto.randomUUID(), text: newTodoText }), 17 | events.uiStateSet({ newTodoText: "" }) 18 | ); 19 | 20 | return ( 21 |
22 |

TodoMVC

23 | updatedNewTodoText(e.target.value)} 29 | onKeyDown={(e) => { 30 | if (e.key === "Enter") { 31 | todoCreated(); 32 | } 33 | }} 34 | > 35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /apps/todomvc/src/components/MainSection.tsx: -------------------------------------------------------------------------------- 1 | import { queryDb } from "@livestore/livestore"; 2 | import { useStore } from "@livestore/react"; 3 | import React from "react"; 4 | 5 | import { uiState$ } from "../livestore/queries.js"; 6 | import { events, tables } from "../livestore/schema.js"; 7 | 8 | const visibleTodos$ = queryDb( 9 | (get) => { 10 | const { filter } = get(uiState$); 11 | return tables.todos.where({ 12 | deletedAt: null, 13 | completed: filter === "all" ? undefined : filter === "completed" 14 | }); 15 | }, 16 | { label: "visibleTodos" } 17 | ); 18 | 19 | export const MainSection: React.FC = () => { 20 | const { store } = useStore(); 21 | 22 | const toggleTodo = React.useCallback( 23 | ({ id, completed }: typeof tables.todos.Type) => 24 | store.commit( 25 | completed 26 | ? events.todoUncompleted({ id }) 27 | : events.todoCompleted({ id }) 28 | ), 29 | [store] 30 | ); 31 | 32 | const visibleTodos = store.useQuery(visibleTodos$); 33 | 34 | return ( 35 |
36 |
    37 | {visibleTodos.map((todo) => ( 38 |
  • 39 |
    40 | toggleTodo(todo)} 45 | /> 46 | 47 | 55 |
    56 |
  • 57 | ))} 58 |
59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /apps/todomvc/src/livestore.worker.ts: -------------------------------------------------------------------------------- 1 | import { schema } from "./livestore/schema.js"; 2 | import { makeWorker } from "@livestore/adapter-web/worker"; 3 | import { makeCfSync } from "@livestore/sync-cf"; 4 | 5 | makeWorker({ 6 | schema, 7 | sync: { 8 | backend: makeCfSync({ 9 | url: import.meta.env.VITE_LIVESTORE_SYNC_URL as string 10 | }), 11 | initialSyncOptions: { _tag: "Blocking", timeout: 5000 } 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /apps/todomvc/src/livestore/queries.ts: -------------------------------------------------------------------------------- 1 | import { queryDb } from "@livestore/livestore"; 2 | 3 | import { tables } from "./schema.js"; 4 | 5 | export const uiState$ = queryDb(tables.uiState.get(), { label: "uiState" }); 6 | -------------------------------------------------------------------------------- /apps/todomvc/src/livestore/schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Events, 3 | makeSchema, 4 | Schema, 5 | SessionIdSymbol, 6 | State 7 | } from "@livestore/livestore"; 8 | 9 | // You can model your state as SQLite tables (https://docs.livestore.dev/reference/state/sqlite-schema) 10 | export const tables = { 11 | todos: State.SQLite.table({ 12 | name: "todos", 13 | columns: { 14 | id: State.SQLite.text({ primaryKey: true }), 15 | text: State.SQLite.text({ default: "" }), 16 | completed: State.SQLite.boolean({ default: false }), 17 | deletedAt: State.SQLite.integer({ 18 | nullable: true, 19 | schema: Schema.DateFromNumber 20 | }) 21 | } 22 | }), 23 | // Client documents can be used for local-only state (e.g. form inputs) 24 | uiState: State.SQLite.clientDocument({ 25 | name: "uiState", 26 | schema: Schema.Struct({ 27 | newTodoText: Schema.String, 28 | filter: Schema.Literal("all", "active", "completed") 29 | }), 30 | default: { id: SessionIdSymbol, value: { newTodoText: "", filter: "all" } } 31 | }) 32 | }; 33 | 34 | // Events describe data changes (https://docs.livestore.dev/reference/events) 35 | export const events = { 36 | todoCreated: Events.synced({ 37 | name: "v1.TodoCreated", 38 | schema: Schema.Struct({ id: Schema.String, text: Schema.String }) 39 | }), 40 | todoCompleted: Events.synced({ 41 | name: "v1.TodoCompleted", 42 | schema: Schema.Struct({ id: Schema.String }) 43 | }), 44 | todoUncompleted: Events.synced({ 45 | name: "v1.TodoUncompleted", 46 | schema: Schema.Struct({ id: Schema.String }) 47 | }), 48 | todoDeleted: Events.synced({ 49 | name: "v1.TodoDeleted", 50 | schema: Schema.Struct({ id: Schema.String, deletedAt: Schema.Date }) 51 | }), 52 | todoClearedCompleted: Events.synced({ 53 | name: "v1.TodoClearedCompleted", 54 | schema: Schema.Struct({ deletedAt: Schema.Date }) 55 | }), 56 | uiStateSet: tables.uiState.set 57 | }; 58 | 59 | // Materializers are used to map events to state (https://docs.livestore.dev/reference/state/materializers) 60 | const materializers = State.SQLite.materializers(events, { 61 | "v1.TodoCreated": ({ id, text }) => 62 | tables.todos.insert({ id, text, completed: false }), 63 | "v1.TodoCompleted": ({ id }) => 64 | tables.todos.update({ completed: true }).where({ id }), 65 | "v1.TodoUncompleted": ({ id }) => 66 | tables.todos.update({ completed: false }).where({ id }), 67 | "v1.TodoDeleted": ({ id, deletedAt }) => 68 | tables.todos.update({ deletedAt }).where({ id }), 69 | "v1.TodoClearedCompleted": ({ deletedAt }) => 70 | tables.todos.update({ deletedAt }).where({ completed: true }) 71 | }); 72 | 73 | const state = State.SQLite.makeState({ tables, materializers }); 74 | 75 | export const schema = makeSchema({ events, state }); 76 | -------------------------------------------------------------------------------- /apps/todomvc/src/server.ts: -------------------------------------------------------------------------------- 1 | import { makeDurableObject, makeWorker } from "@livestore/sync-cf/cf-worker"; 2 | 3 | export class WebSocketServer extends makeDurableObject({ 4 | onPush: async (message) => { 5 | console.log("onPush", message.batch); 6 | }, 7 | onPull: async (message) => { 8 | console.log("onPull", message); 9 | } 10 | }) {} 11 | 12 | export default makeWorker({ 13 | validatePayload: (payload: any) => { 14 | if (payload?.authToken !== "insecure-token-change-me") { 15 | throw new Error("Invalid auth token"); 16 | } 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /apps/todomvc/src/util/store-id.ts: -------------------------------------------------------------------------------- 1 | export const getStoreId = () => { 2 | if (typeof window === "undefined") return "unused"; 3 | 4 | const searchParams = new URLSearchParams(window.location.search); 5 | const storeId = searchParams.get("storeId"); 6 | if (storeId !== null) return storeId; 7 | 8 | const newAppId = crypto.randomUUID(); 9 | searchParams.set("storeId", newAppId); 10 | 11 | window.location.search = searchParams.toString(); 12 | }; 13 | -------------------------------------------------------------------------------- /apps/todomvc/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /apps/todomvc/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import { cloudflare } from "@cloudflare/vite-plugin"; 4 | import { livestoreDevtoolsPlugin } from "@livestore/devtools-vite"; 5 | import devtoolsJson from "vite-plugin-devtools-json"; 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | devtoolsJson(), 10 | react(), 11 | cloudflare(), 12 | livestoreDevtoolsPlugin({ schemaPath: "./src/livestore/schema.ts" }) 13 | ] 14 | }); 15 | -------------------------------------------------------------------------------- /apps/todomvc/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "name": "livestore-todomvc", 3 | "main": "./src/server.ts", 4 | "compatibility_date": "2025-05-08", 5 | "compatibility_flags": ["nodejs_compat"], 6 | "durable_objects": { 7 | "bindings": [ 8 | { 9 | "name": "WEBSOCKET_SERVER", 10 | "class_name": "WebSocketServer" 11 | } 12 | ] 13 | }, 14 | "migrations": [ 15 | { 16 | "tag": "v1", 17 | "new_sqlite_classes": ["WebSocketServer"] 18 | } 19 | ], 20 | // "d1_databases": [ 21 | // { 22 | // "binding": "DB", 23 | // "database_name": "livestore-todomvc", 24 | // "database_id": "1c9b5dae-f1fa-49d8-83fa-7bd5b39c4121" 25 | // // "database_id": "${LIVESTORE_CF_SYNC_DATABASE_ID}" 26 | // } 27 | // ], 28 | "vars": { 29 | // should be set via CF dashboard (as secret) 30 | // "ADMIN_SECRET": "..." 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "livestore-cf-examples", 3 | "private": true, 4 | "version": "0.0.0", 5 | "description": "Examples of using livestore with cloudflare workers", 6 | "keywords": [], 7 | "author": "Sunil Pai ", 8 | "license": "ISC", 9 | "type": "module", 10 | "workspaces": [ 11 | "apps/*" 12 | ], 13 | "scripts": { 14 | "format": "prettier --write .", 15 | "postinstall": "patch-package", 16 | "typecheck": "find apps/*/package.json -print0 | xargs -0 -n1 dirname | xargs -I{} bash -c 'echo \"==> Running tsc in {}\" && cd \"{}\" && npx tsc'" 17 | }, 18 | "devDependencies": { 19 | "@cloudflare/vite-plugin": "^1.3.0", 20 | "@cloudflare/workers-types": "^4.20250528.0", 21 | "@livestore/adapter-web": "0.3.0", 22 | "@livestore/devtools-vite": "0.3.0", 23 | "@livestore/livestore": "0.3.0", 24 | "@livestore/peer-deps": "0.3.0", 25 | "@livestore/react": "0.3.0", 26 | "@livestore/sync-cf": "0.3.0", 27 | "@livestore/wa-sqlite": "1.0.5-dev.2", 28 | "@overengineering/fps-meter": "0.1.2", 29 | "@types/node": "^22.15.23", 30 | "@types/react": "^19.1.6", 31 | "@types/react-dom": "^19.1.5", 32 | "@vitejs/plugin-react": "^4.5.0", 33 | "patch-package": "^8.0.0", 34 | "prettier": "^3.5.3", 35 | "react": "^19.1.0", 36 | "react-dom": "^19.1.0", 37 | "typescript": "^5.8.3", 38 | "vite": "^6.3.5", 39 | "vite-plugin-devtools-json": "^0.1.0", 40 | "wrangler": "^4.17.0" 41 | }, 42 | "packageManager": "npm@11.4.1" 43 | } 44 | -------------------------------------------------------------------------------- /patches/@livestore+sync-cf+0.3.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@livestore/sync-cf/dist/cf-worker/durable-object.js b/node_modules/@livestore/sync-cf/dist/cf-worker/durable-object.js 2 | index b7ebe01..a034396 100644 3 | --- a/node_modules/@livestore/sync-cf/dist/cf-worker/durable-object.js 4 | +++ b/node_modules/@livestore/sync-cf/dist/cf-worker/durable-object.js 5 | @@ -46,7 +46,7 @@ export const makeDurableObject = (options) => { 6 | this.ctx.acceptWebSocket(server); 7 | this.ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(encodeIncomingMessage(WSMessage.Ping.make({ requestId: 'ping' })), encodeOutgoingMessage(WSMessage.Pong.make({ requestId: 'ping' })))); 8 | const colSpec = makeColumnSpec(eventlogTable.sqliteDef.ast); 9 | - this.env.DB.exec(`CREATE TABLE IF NOT EXISTS ${storage.dbName} (${colSpec}) strict`); 10 | + this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS ${storage.dbName} (${colSpec}) strict`); 11 | return new Response(null, { 12 | status: 101, 13 | webSocket: client, 14 | @@ -210,10 +210,10 @@ export const makeDurableObject = (options) => { 15 | }; 16 | const makeStorage = (ctx, env, storeId) => { 17 | const dbName = `eventlog_${PERSISTENCE_FORMAT_VERSION}_${toValidTableName(storeId)}`; 18 | - const execDb = (cb) => Effect.tryPromise({ 19 | - try: () => cb(env.DB), 20 | + const execDb = (cb) => Effect.try({ 21 | + try: () => cb(ctx.storage.sql), 22 | catch: (error) => new UnexpectedError({ cause: error, payload: { dbName } }), 23 | - }).pipe(Effect.map((_) => _.results), Effect.withSpan('@livestore/sync-cf:durable-object:execDb')); 24 | + }).pipe(Effect.map((_) => _), Effect.withSpan('@livestore/sync-cf:durable-object:execDb')); 25 | // const getHead: Effect.Effect = Effect.gen( 26 | // function* () { 27 | // const result = yield* execDb<{ seqNum: EventSequenceNumber.GlobalEventSequenceNumber }>((db) => 28 | @@ -226,7 +226,7 @@ const makeStorage = (ctx, env, storeId) => { 29 | const whereClause = cursor === undefined ? '' : `WHERE seqNum > ${cursor}`; 30 | const sql = `SELECT * FROM ${dbName} ${whereClause} ORDER BY seqNum ASC`; 31 | // TODO handle case where `cursor` was not found 32 | - const rawEvents = yield* execDb((db) => db.prepare(sql).all()); 33 | + const rawEvents = yield* execDb((db) => [...db.exec(sql)]); 34 | const events = Schema.decodeUnknownSync(Schema.Array(eventlogTable.rowSchema))(rawEvents).map(({ createdAt, ...eventEncoded }) => ({ 35 | eventEncoded, 36 | metadata: Option.some({ createdAt }), 37 | @@ -256,10 +256,8 @@ const makeStorage = (ctx, env, storeId) => { 38 | event.clientId, 39 | event.sessionId, 40 | ]); 41 | - yield* execDb((db) => db 42 | - .prepare(sql) 43 | - .bind(...params) 44 | - .run()); 45 | + yield* execDb((db) => [...db 46 | + .exec(sql, ...params)]); 47 | } 48 | }).pipe(UnexpectedError.mapToUnexpectedError); 49 | const resetStore = Effect.gen(function* () { 50 | --------------------------------------------------------------------------------