├── .prettierrc
├── apps
├── chat
│ ├── tsconfig.json
│ ├── public
│ │ └── favicon.ico
│ ├── src
│ │ ├── livestore
│ │ │ ├── queries.ts
│ │ │ └── schema.ts
│ │ ├── client.tsx
│ │ ├── livestore.worker.ts
│ │ ├── util
│ │ │ └── store-id.ts
│ │ ├── server.ts
│ │ └── app.tsx
│ ├── README.md
│ ├── index.html
│ ├── package.json
│ ├── vite.config.ts
│ └── wrangler.jsonc
├── todomvc
│ ├── tsconfig.json
│ ├── public
│ │ └── favicon.ico
│ ├── src
│ │ ├── livestore
│ │ │ ├── queries.ts
│ │ │ └── schema.ts
│ │ ├── client.tsx
│ │ ├── livestore.worker.ts
│ │ ├── util
│ │ │ └── store-id.ts
│ │ ├── server.ts
│ │ ├── components
│ │ │ ├── Header.tsx
│ │ │ ├── MainSection.tsx
│ │ │ └── Footer.tsx
│ │ └── app.tsx
│ ├── README.md
│ ├── index.html
│ ├── vite.config.ts
│ ├── package.json
│ └── wrangler.jsonc
├── linearlite
│ ├── src
│ │ ├── types
│ │ │ ├── description.ts
│ │ │ ├── comment.ts
│ │ │ ├── status.ts
│ │ │ ├── priority.ts
│ │ │ └── issue.ts
│ │ ├── utils
│ │ │ ├── get-issue-tag.ts
│ │ │ ├── get-acronym.ts
│ │ │ └── format-date.ts
│ │ ├── app
│ │ │ ├── main.tsx
│ │ │ ├── contexts.ts
│ │ │ ├── app.tsx
│ │ │ ├── style.css
│ │ │ └── provider.tsx
│ │ ├── data
│ │ │ ├── theme-options.ts
│ │ │ ├── sorting-options.ts
│ │ │ ├── priority-options.ts
│ │ │ └── status-options.ts
│ │ ├── lib
│ │ │ └── livestore
│ │ │ │ ├── worker.ts
│ │ │ │ ├── schema
│ │ │ │ ├── description.ts
│ │ │ │ ├── scroll-state.ts
│ │ │ │ ├── comment.ts
│ │ │ │ ├── frontend-state.ts
│ │ │ │ ├── issue.ts
│ │ │ │ ├── filter-state.ts
│ │ │ │ └── index.ts
│ │ │ │ ├── queries.ts
│ │ │ │ ├── utils.tsx
│ │ │ │ ├── events.ts
│ │ │ │ └── seed.ts
│ │ ├── components
│ │ │ ├── common
│ │ │ │ ├── avatar.tsx
│ │ │ │ ├── shortcut.tsx
│ │ │ │ ├── menu-button.tsx
│ │ │ │ ├── modal.tsx
│ │ │ │ ├── editor.tsx
│ │ │ │ ├── status-menu.tsx
│ │ │ │ ├── priority-menu.tsx
│ │ │ │ └── editor-menu.tsx
│ │ │ ├── icons
│ │ │ │ ├── priority-high.tsx
│ │ │ │ ├── priority-none.tsx
│ │ │ │ ├── filter.tsx
│ │ │ │ ├── priority-urgent.tsx
│ │ │ │ ├── linear-lite.tsx
│ │ │ │ ├── priority-medium.tsx
│ │ │ │ ├── priority-low.tsx
│ │ │ │ ├── sidebar.tsx
│ │ │ │ ├── todo.tsx
│ │ │ │ ├── backlog.tsx
│ │ │ │ ├── done.tsx
│ │ │ │ ├── in-progress.tsx
│ │ │ │ ├── canceled.tsx
│ │ │ │ ├── new-issue.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ └── livestore.tsx
│ │ │ └── layout
│ │ │ │ ├── issue
│ │ │ │ ├── description-input.tsx
│ │ │ │ ├── back-button.tsx
│ │ │ │ ├── title-input.tsx
│ │ │ │ ├── delete-button.tsx
│ │ │ │ ├── comments.tsx
│ │ │ │ ├── comment-input.tsx
│ │ │ │ └── new-issue-modal.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── filters
│ │ │ │ ├── header.tsx
│ │ │ │ ├── priority-filter.tsx
│ │ │ │ ├── status-filter.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── sort-menu.tsx
│ │ │ │ └── filter-menu.tsx
│ │ │ │ ├── list
│ │ │ │ ├── virtual-row.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── filtered-list.tsx
│ │ │ │ └── row.tsx
│ │ │ │ ├── toolbar
│ │ │ │ ├── download-button.tsx
│ │ │ │ ├── toolbar-button.tsx
│ │ │ │ ├── reset-button.tsx
│ │ │ │ ├── devtools-button.tsx
│ │ │ │ ├── user-input.tsx
│ │ │ │ ├── sync-toggle.tsx
│ │ │ │ ├── seed-input.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── mobile-menu.tsx
│ │ │ │ └── share-button.tsx
│ │ │ │ ├── sidebar
│ │ │ │ ├── search-button.tsx
│ │ │ │ ├── mobile-menu.tsx
│ │ │ │ ├── new-issue-button.tsx
│ │ │ │ ├── about-modal.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── about-menu.tsx
│ │ │ │ └── theme-button.tsx
│ │ │ │ ├── search
│ │ │ │ ├── index.tsx
│ │ │ │ └── search-bar.tsx
│ │ │ │ └── board
│ │ │ │ ├── draggable.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ └── card.tsx
│ │ ├── server.ts
│ │ └── hooks
│ │ │ ├── useLockBodyScroll.ts
│ │ │ ├── useDebounce.ts
│ │ │ └── useClickOutside.ts
│ ├── public
│ │ ├── netlify.toml
│ │ ├── favicon.ico
│ │ └── fonts
│ │ │ ├── inter-medium.woff
│ │ │ ├── inter-medium.woff2
│ │ │ ├── inter-regular.woff
│ │ │ ├── inter-regular.woff2
│ │ │ ├── inter-semibold.woff
│ │ │ ├── inter-extrabold.woff
│ │ │ ├── inter-extrabold.woff2
│ │ │ └── inter-semibold.woff2
│ ├── README.md
│ ├── tsconfig.json
│ ├── wrangler.jsonc
│ ├── tailwind.config.cjs
│ ├── index.html
│ ├── vite.config.ts
│ └── package.json
└── react-router
│ ├── public
│ └── favicon.ico
│ ├── app
│ ├── routes.ts
│ ├── app.css
│ ├── livestore
│ │ ├── livestore.worker.ts
│ │ └── schema.ts
│ ├── routes
│ │ └── home.tsx
│ ├── counter.tsx
│ ├── entry.server.tsx
│ ├── server.ts
│ └── root.tsx
│ ├── .react-router
│ └── types
│ │ ├── +future.ts
│ │ ├── +routes.ts
│ │ ├── +server-build.d.ts
│ │ └── app
│ │ ├── +types
│ │ └── root.ts
│ │ └── routes
│ │ └── +types
│ │ └── home.ts
│ ├── react-router.config.ts
│ ├── README.md
│ ├── tsconfig.json
│ ├── package.json
│ ├── vite.config.ts
│ └── wrangler.jsonc
├── README.md
├── package.json
├── .gitignore
└── patches
└── @livestore+sync-cf+0.3.0.patch
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "none"
3 | }
4 |
--------------------------------------------------------------------------------
/apps/chat/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json"
3 | }
4 |
--------------------------------------------------------------------------------
/apps/todomvc/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json"
3 | }
4 |
--------------------------------------------------------------------------------
/apps/linearlite/src/types/description.ts:
--------------------------------------------------------------------------------
1 | export type Description = {
2 | id: string;
3 | body: string;
4 | };
5 |
--------------------------------------------------------------------------------
/apps/linearlite/public/netlify.toml:
--------------------------------------------------------------------------------
1 |
2 | [[redirects]]
3 | from = "/*"
4 | to = "/index.html"
5 | status = 200
6 |
--------------------------------------------------------------------------------
/apps/chat/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/threepointone/livestore-cf-examples/HEAD/apps/chat/public/favicon.ico
--------------------------------------------------------------------------------
/apps/todomvc/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/threepointone/livestore-cf-examples/HEAD/apps/todomvc/public/favicon.ico
--------------------------------------------------------------------------------
/apps/linearlite/src/utils/get-issue-tag.ts:
--------------------------------------------------------------------------------
1 | export const getIssueTag = (issueId: number) => {
2 | return `ISS-${issueId}`;
3 | };
4 |
--------------------------------------------------------------------------------
/apps/linearlite/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/threepointone/livestore-cf-examples/HEAD/apps/linearlite/public/favicon.ico
--------------------------------------------------------------------------------
/apps/react-router/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/threepointone/livestore-cf-examples/HEAD/apps/react-router/public/favicon.ico
--------------------------------------------------------------------------------
/apps/linearlite/public/fonts/inter-medium.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/threepointone/livestore-cf-examples/HEAD/apps/linearlite/public/fonts/inter-medium.woff
--------------------------------------------------------------------------------
/apps/linearlite/public/fonts/inter-medium.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/threepointone/livestore-cf-examples/HEAD/apps/linearlite/public/fonts/inter-medium.woff2
--------------------------------------------------------------------------------
/apps/linearlite/public/fonts/inter-regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/threepointone/livestore-cf-examples/HEAD/apps/linearlite/public/fonts/inter-regular.woff
--------------------------------------------------------------------------------
/apps/linearlite/public/fonts/inter-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/threepointone/livestore-cf-examples/HEAD/apps/linearlite/public/fonts/inter-regular.woff2
--------------------------------------------------------------------------------
/apps/linearlite/public/fonts/inter-semibold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/threepointone/livestore-cf-examples/HEAD/apps/linearlite/public/fonts/inter-semibold.woff
--------------------------------------------------------------------------------
/apps/linearlite/public/fonts/inter-extrabold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/threepointone/livestore-cf-examples/HEAD/apps/linearlite/public/fonts/inter-extrabold.woff
--------------------------------------------------------------------------------
/apps/linearlite/public/fonts/inter-extrabold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/threepointone/livestore-cf-examples/HEAD/apps/linearlite/public/fonts/inter-extrabold.woff2
--------------------------------------------------------------------------------
/apps/linearlite/public/fonts/inter-semibold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/threepointone/livestore-cf-examples/HEAD/apps/linearlite/public/fonts/inter-semibold.woff2
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/chat/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | TodoMVC
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/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/todomvc/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | TodoMVC
7 |
8 |
9 |
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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-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/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/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/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/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/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/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/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/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/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/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-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/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/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/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/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/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/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/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/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 | setShowMenu(true)}
13 | className={`size-8 shrink-0 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 rounded-lg bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 shadow ${className}`}
14 | >
15 |
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/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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/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 |
19 |
20 | Download
21 |
22 |
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/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 |
20 |
21 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/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/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/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/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/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/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | LinearLite
8 |
9 |
10 |
14 |
15 |
16 |
17 |
18 |
19 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/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/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/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 |
25 |
26 |
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/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/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/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 |
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/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 |
28 |
29 | {confirm ? "Confirm" : "Reset"}
30 |
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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 | {
16 | setNewIssueModalStatus(status ?? 0);
17 | setShowMenu(false);
18 | }}
19 | className={`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 rounded-lg ${status === undefined ? "bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 shadow" : ""}`}
20 | >
21 | {status === undefined ? (
22 |
23 | ) : (
24 |
25 | )}
26 |
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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 |
28 | )}
29 |
30 | {() => (
31 |
32 |
33 |
34 | )}
35 |
36 |
37 |
38 | );
39 | }
40 | );
41 |
--------------------------------------------------------------------------------
/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/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/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/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 |
20 | store.commit(events.counterDecremented({ amount: counter - 1 }))
21 | }
22 | className="px-4 py-2 text-white bg-blue-500 rounded hover:bg-blue-600 transition-colors"
23 | >
24 | Decrement
25 |
26 |
28 | store.commit(events.counterIncremented({ amount: counter + 1 }))
29 | }
30 | className="px-4 py-2 text-white bg-blue-500 rounded hover:bg-blue-600 transition-colors"
31 | >
32 | Increment
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/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 |
32 |
33 | Seed
34 |
35 |
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
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/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/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/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 |
42 |
43 | {confirm && (
44 |
45 | Confirm delete
46 |
47 | )}
48 |
49 | );
50 | };
51 |
--------------------------------------------------------------------------------
/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/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/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/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/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 | setFilterState({ query: null })}
35 | className="absolute right-2 size-8 rounded-lg hover:bg-neutral-100 focus:bg-neutral-100 flex items-center justify-center"
36 | >
37 |
38 |
39 | )}
40 |
41 | );
42 | };
43 |
--------------------------------------------------------------------------------
/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/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/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 |
60 | );
61 | };
62 |
--------------------------------------------------------------------------------
/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/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 | setShow(false)}
48 | className="absolute top-2 right-2 size-8 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-700 focus:outline-none focus:bg-neutral-700 flex items-center justify-center"
49 | >
50 |
51 |
52 |
53 |
54 | );
55 | };
56 |
--------------------------------------------------------------------------------
/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/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/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 |
23 | Tools
24 |
25 |
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/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 |
24 | {incompleteCount} items left
25 |
54 |
57 | store.commit(events.todoClearedCompleted({ deletedAt: new Date() }))
58 | }
59 | >
60 | Clear completed
61 |
62 |
63 | );
64 | };
65 |
--------------------------------------------------------------------------------
/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/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 |
43 |
44 |
45 | {getIssueTag(issue.id)}
46 |
47 |
48 |
49 |
50 |
51 |
{issue.title}
52 |
53 |
58 |
59 | );
60 | };
61 |
--------------------------------------------------------------------------------
/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/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 |
61 |
62 |
63 |
64 | );
65 | };
66 |
--------------------------------------------------------------------------------
/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/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 |
26 | {filterState.priority.length === 1 ? (
27 | <>
28 |
35 |
36 | {priorityOptions[filterState.priority[0] as Priority]!.name}
37 |
38 | >
39 | ) : (
40 | {filterState.priority.length} priorities
41 | )}
42 |
43 |
44 |
setFilterState({ priority: null })}
46 | className="h-full flex items-center px-1 group hover:bg-neutral-50 dark:hover:bg-neutral-800 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 border-l border-neutral-200 dark:border-neutral-700"
47 | >
48 |
49 |
50 |
51 | );
52 | };
53 |
--------------------------------------------------------------------------------
/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 |
24 | {filterState.status.map((status, index) => (
25 |
29 |
33 |
34 | ))}
35 | {filterState.status.length === 1 ? (
36 |
37 | {statusOptions[filterState.status[0] as Status]!.name}
38 |
39 | ) : (
40 | {filterState.status.length} statuses
41 | )}
42 |
43 |
44 |
setFilterState({ status: null })}
46 | className="h-full flex items-center px-1 group hover:bg-neutral-50 dark:hover:bg-neutral-800 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 border-l border-neutral-200 dark:border-neutral-700"
47 | >
48 |
49 |
50 |
51 | );
52 | };
53 |
--------------------------------------------------------------------------------
/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/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 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | Issues
45 |
46 |
47 | {navItems.map(({ title, icon: Icon, href, onClick }, index) => (
48 | {
52 | onClick();
53 | setShowMenu(false);
54 | }}
55 | className="flex items-center gap-2 px-2 h-8 rounded-md focus:outline-none dark:hover:bg-neutral-800 dark:focus:bg-neutral-800 hover:bg-neutral-100 focus:bg-neutral-100"
56 | >
57 |
58 | {title}
59 |
60 | ))}
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | );
69 | };
70 |
--------------------------------------------------------------------------------
/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/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/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 |
33 | {copied ? (
34 | <>
35 |
36 |
37 | URL copied!
38 | Copied!
39 |
40 | >
41 | ) : (
42 | <>
43 |
44 |
45 | Share workspace
46 |
47 | >
48 | )}
49 |
50 | setShowQR(true)}
53 | className="size-6 flex items-center justify-center bg-neutral-800 rounded hover:bg-neutral-700 focus:outline-none focus:bg-neutral-800"
54 | >
55 |
56 |
57 |
58 |
64 |
65 |
71 |
72 |
73 | >
74 | );
75 | };
76 |
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/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/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 |
49 |
53 | {showLabel && {statusOptions[status]!.name} }
54 |
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/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 |
26 |
30 | LinearLite
31 |
32 |
33 |
34 |
35 |
36 |
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 |
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/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 |
49 |
53 | {showLabel && {priorityOptions[priority]!.name} }
54 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
59 |
60 |
67 | Filter
68 |
69 |
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/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/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 |
64 |
65 |
66 |
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/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/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 |
101 | Create issue
102 |
103 |
104 |
105 |
106 | );
107 | };
108 |
--------------------------------------------------------------------------------
/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 | editor.chain().focus().toggleBold().run()}
24 | isDisabled={!editor.can().chain().focus().toggleBold().run()}
25 | className={`rounded-md size-7 shrink-0 flex items-center justify-center hover:bg-neutral-100 hover:text-neutral-800 focus:text-neutral-800 focus:outline-none focus:bg-neutral-100 ${editor.isActive("bold") ? "bg-neutral-100 text-neutral-800" : ""}`}
26 | >
27 |
28 |
29 | editor.chain().focus().toggleItalic().run()}
31 | isDisabled={!editor.can().chain().focus().toggleItalic().run()}
32 | className={`rounded-md size-7 shrink-0 flex items-center justify-center hover:bg-neutral-100 hover:text-neutral-800 focus:text-neutral-800 focus:outline-none focus:bg-neutral-100 ${editor.isActive("italic") ? "bg-neutral-100 text-neutral-800" : ""}`}
33 | >
34 |
35 |
36 | editor.chain().focus().toggleStrike().run()}
38 | isDisabled={!editor.can().chain().focus().toggleStrike().run()}
39 | className={`rounded-md size-7 shrink-0 flex items-center justify-center hover:bg-neutral-100 hover:text-neutral-800 focus:text-neutral-800 focus:outline-none focus:bg-neutral-100 ${editor.isActive("strike") ? "bg-neutral-100 text-neutral-800" : ""}`}
40 | >
41 |
42 |
43 | editor.chain().focus().toggleCode().run()}
45 | isDisabled={!editor.can().chain().focus().toggleCode().run()}
46 | className={`rounded-md size-7 shrink-0 flex items-center justify-center hover:bg-neutral-100 hover:text-neutral-800 focus:text-neutral-800 focus:outline-none focus:bg-neutral-100 ${editor.isActive("code") ? "bg-neutral-100 text-neutral-800" : ""}`}
47 | >
48 |
49 |
50 |
51 |
52 | editor.chain().focus().toggleBulletList().run()}
54 | isDisabled={!editor.can().chain().focus().toggleBulletList().run()}
55 | className={`rounded-md size-7 shrink-0 flex items-center justify-center hover:bg-neutral-100 hover:text-neutral-800 focus:text-neutral-800 focus:outline-none focus:bg-neutral-100 ${editor.isActive("bulletList") ? "bg-neutral-100 text-neutral-800" : ""}`}
56 | >
57 |
58 |
59 | editor.chain().focus().toggleOrderedList().run()}
61 | isDisabled={!editor.can().chain().focus().toggleOrderedList().run()}
62 | className={`rounded-md size-7 shrink-0 flex items-center justify-center hover:bg-neutral-100 hover:text-neutral-800 focus:text-neutral-800 focus:outline-none focus:bg-neutral-100 ${editor.isActive("orderedList") ? "bg-neutral-100 text-neutral-800" : ""}`}
63 | >
64 |
65 |
66 | editor.chain().focus().toggleCodeBlock().run()}
68 | isDisabled={!editor.can().chain().focus().toggleCodeBlock().run()}
69 | className={`rounded-md size-7 shrink-0 flex items-center justify-center hover:bg-neutral-100 hover:text-neutral-800 focus:text-neutral-800 focus:outline-none focus:bg-neutral-100 ${editor.isActive("codeBlock") ? "bg-neutral-100 text-neutral-800" : ""}`}
70 | >
71 |
72 |
73 |
74 |
75 | );
76 | };
77 |
78 | export default EditorMenu;
79 |
--------------------------------------------------------------------------------
/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 |
67 |
68 | Sort
69 |
70 |
71 |
72 |
77 | {type !== "priority" && (
78 |
79 |
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/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/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 |
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 |
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 |
--------------------------------------------------------------------------------