diff --git a/README.md b/README.md index f8ba1691..92c2a8b2 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,14 @@ Demo applications are located in the [`demos/`](./demos/) directory. Also see ou ### React Native -- [demos/react-native-supabase-todolist](./demos/react-native-supabase-todolist): A React Native to-do list example app using a Supabase backend. -- [demos/django-react-native-todolist](./demos/django-react-native-todolist) A React Native to-do list example app using a Django backend. +- [demos/react-native-supabase-todolist](./demos/react-native-supabase-todolist/README.md): A React Native to-do list example app using a Supabase backend. +- [demos/react-native-supabase-group-chat](./demos/react-native-supabase-group-chat/README.md): A React Native group chat example app using a Supabase backend. +- [demos/django-react-native-todolist](./demos/django-react-native-todolist/README.md) A React Native to-do list example app using a Django backend. ### Web - [demos/react-supabase-todolist](./demos/react-supabase-todolist/README.md): A React to-do list example app using the PowerSync Web SDK and a Supabase backend. +- [demos/react-multi-client](./demos/react-multi-client/README.md): A React widget that illustrates how data flows from one PowerSync client to another. - [demos/yjs-react-supabase-text-collab](./demos/yjs-react-supabase-text-collab/README.md): A React real-time text editing collaboration example app powered by [Yjs](https://github.com/yjs/yjs) CRDTs and [Tiptap](https://tiptap.dev/), using the PowerSync Web SDK and a Supabase backend. - [demos/vue-supabase-todolist](./demos/vue-supabase-todolist/README.md): A Vue to-do list example app using the PowerSync Web SDK and a Supabase backend. - [demos/angular-supabase-todolist](./demos/angular-supabase-todolist/README.md) An Angular to-do list example app using the PowerSync Web SDK and a Supabase backend. diff --git a/demos/react-multi-client/.env.local.template b/demos/react-multi-client/.env.local.template new file mode 100644 index 00000000..dc4088ca --- /dev/null +++ b/demos/react-multi-client/.env.local.template @@ -0,0 +1,5 @@ +# Copy this template: `cp .env.local.template .env.local` +# Edit .env.local and enter your Supabase and PowerSync project details. +VITE_SUPABASE_URL=https://foo.supabase.co +VITE_SUPABASE_ANON_KEY=foo +VITE_POWERSYNC_URL=https://foo.powersync.journeyapps.com diff --git a/demos/react-multi-client/.envrc b/demos/react-multi-client/.envrc new file mode 100644 index 00000000..013a35c2 --- /dev/null +++ b/demos/react-multi-client/.envrc @@ -0,0 +1,3 @@ +layout node +use node +[ -f .env ] && dotenv \ No newline at end of file diff --git a/demos/react-multi-client/.gitignore b/demos/react-multi-client/.gitignore new file mode 100644 index 00000000..d857f4f0 --- /dev/null +++ b/demos/react-multi-client/.gitignore @@ -0,0 +1,38 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + + +# production +/build +/dist + +# supabase cli +supabase + +# misc +.DS_Store +*.pem +.idea + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo \ No newline at end of file diff --git a/demos/react-multi-client/.nvmrc b/demos/react-multi-client/.nvmrc new file mode 100644 index 00000000..c946e1df --- /dev/null +++ b/demos/react-multi-client/.nvmrc @@ -0,0 +1 @@ +v20.9.0 \ No newline at end of file diff --git a/demos/react-multi-client/.postcss.config.js b/demos/react-multi-client/.postcss.config.js new file mode 100644 index 00000000..5bfb8f62 --- /dev/null +++ b/demos/react-multi-client/.postcss.config.js @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + autoprefixer: {} + } +}; diff --git a/demos/react-multi-client/.prettierignore b/demos/react-multi-client/.prettierignore new file mode 100644 index 00000000..e6f15b8f --- /dev/null +++ b/demos/react-multi-client/.prettierignore @@ -0,0 +1,11 @@ +# Ignore all node_modules +**/node_modules/** + +# Autogenerated files +**/.idea/** +**/.fleet/** +**/devlink/** + +# Other +**/assets/** +**/bin/** \ No newline at end of file diff --git a/demos/react-multi-client/.prettierrc b/demos/react-multi-client/.prettierrc new file mode 100644 index 00000000..b33c4f12 --- /dev/null +++ b/demos/react-multi-client/.prettierrc @@ -0,0 +1,7 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "singleQuote": true, + "bracketSameLine": true, + "trailingComma": "none" +} \ No newline at end of file diff --git a/demos/react-multi-client/LICENSE b/demos/react-multi-client/LICENSE new file mode 100644 index 00000000..1625c179 --- /dev/null +++ b/demos/react-multi-client/LICENSE @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. \ No newline at end of file diff --git a/demos/react-multi-client/README.md b/demos/react-multi-client/README.md new file mode 100644 index 00000000..6dededec --- /dev/null +++ b/demos/react-multi-client/README.md @@ -0,0 +1,65 @@ +# PowerSync + Supabase Web Demo: Multi Client + +This is a demo of the widget displayed on the [PowerSync homepage](http://powersync.com) and demonstrates how data flows from one PowerSync client to another. It also includes an implementation of Supabase's [anonymous auth](https://supabase.com/docs/guides/auth/auth-anonymous) feature. + +![website-widget](./public/website-widget.png) + +## Webflow Devlink Components + +Note that some of the UI components are generated from elements created in (Webflow's Devlink)[https://webflow.com/devlink]. They can be found under `src/devlink`. To make it easier to modify this project, these generated components are wrapped in facade components. The implementation detail of the facades can easily be changed to your own version that doesn't depend on devlink. + +## Setup Instructions + +Note that this setup guide has minor deviations from the [Supabase + PowerSync integration guide](https://docs.powersync.com/integration-guides/supabase-+-powersync). Below we refer to sections in this guide where relevant. + +### 1. Install dependencies + +In the repo directory, use [pnpm](https://pnpm.io/installation) to install dependencies: + +```bash +pnpm install +pnpm build:packages +``` + +### 2. Create project on Supabase and set up Postgres + +This demo app uses Supabase as its Postgres database and backend: + +1. [Create a new project on the Supabase dashboard](https://supabase.com/dashboard/projects). +2. Go to the Supabase SQL Editor for your new project and execute the SQL statements in [`database.sql`](database.sql) to create the database schema, row level security (RLS) rules, and publication needed for PowerSync. + +### 3. Auth setup + +For ease of demoing, this app uses Supabase's [anonymous sign-in](https://supabase.com/docs/guides/auth/auth-anonymous) feature. +Ensure that it is enabled under "Project Settings" -> "Authentication" in Supabase and confirming `Allow anonymous sign-ins` is toggled on. Click "Save" if you toggled this setting. + +The RLS rules defined in the `database.sql` script are setup to only allow the anonymous user CRUD access to their pebbles. + +### 4. Create new project on PowerSync and connect to Supabase + +Follow the [Connect PowerSync to Your Supabase](https://docs.powersync.com/integration-guides/supabase-+-powersync#connect-powersync-to-your-supabase) section. + +### 5. Create Sync Rules on PowerSync + +Create sync rules by following the [Configure Sync Rules](https://docs.powersync.com/integration-guides/supabase-+-powersync#configure-sync-rules) section. +The sync rules for this demo are defined in [`sync-rules.yaml`](sync-rules.yaml) in this directory. Copy its contents and paste it into the 'sync-rules.yaml' file in the Dashboard as described in the guide. + +### 6. Set up local environment variables + +To set up the environment variables for the demo app, copy the `.env.local.template` file: + +```bash +cp .env.local.template .env.local +``` + +And then edit `.env.local` to insert your credentials for Supabase and PowerSync. + +### 8. Run the demo app + +In this directory, run the following to start the development server: + +```bash +pnpm dev +``` + +Open [http://localhost:5173](http://localhost:5173) with your browser to try out the demo. diff --git a/demos/react-multi-client/database.sql b/demos/react-multi-client/database.sql new file mode 100644 index 00000000..94f3c49d --- /dev/null +++ b/demos/react-multi-client/database.sql @@ -0,0 +1,36 @@ +-- Create pebbles table +create table + public.pebbles ( + id uuid not null default gen_random_uuid (), + created_at timestamp with time zone not null default now(), + shape text not null, + user_id uuid null, + constraint pebbles_pkey primary key (id) +) tablespace pg_default; + +-- Setup RLS for table +alter table public.pebbles enable row level security; + +create policy "owned pebbles" on "public"."pebbles" for ALL using ( + (auth.uid() = user_id) +); + +-- Create publication for powersync +create publication powersync for table public.pebbles; + +-- Create operations table, used for telemetry. This table doesn't need to be synced to the device. +create table + public.operations ( + id uuid not null default gen_random_uuid (), + created_at timestamp with time zone not null default now(), + operation text not null, + user_id uuid null, + constraint operations_pkey primary key (id) +) tablespace pg_default; + +-- Setup RLS for table +alter table public.operations enable row level security; + +create policy "user operations" on "public"."operations" for ALL using ( + (auth.uid() = user_id) +); \ No newline at end of file diff --git a/demos/react-multi-client/package.json b/demos/react-multi-client/package.json new file mode 100644 index 00000000..52f27daf --- /dev/null +++ b/demos/react-multi-client/package.json @@ -0,0 +1,40 @@ +{ + "name": "react-multi-client", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "start": "pnpm build && pnpm preview" + }, + "dependencies": { + "@journeyapps/wa-sqlite": "~0.2.0", + "@powersync/react": "workspace:*", + "@powersync/web": "workspace:*", + "@supabase/supabase-js": "^2.43.1", + "@vitejs/plugin-react": "^4.2.1", + "@webflow/webflow-cli": "^1.6.9", + "async-mutex": "^0.5.0", + "autoprefixer": "10.4.14", + "js-logger": "^1.6.1", + "lodash": "^4.17.21", + "postcss": "8.4.27", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/cors": "~2.8.17", + "@types/lodash": "^4.14.202", + "@types/node": "^20.10.0", + "@types/react": "^18.2.38", + "@types/react-dom": "^18.2.17", + "prettier": "^3.1.0", + "supabase": "^1.165.0", + "typescript": "^5.3.2", + "vite": "^5.1.5", + "vite-plugin-pwa": "^0.19.2", + "vite-plugin-top-level-await": "^1.4.1", + "vite-plugin-wasm": "^3.3.0" + } +} diff --git a/demos/react-multi-client/public/favicon.ico b/demos/react-multi-client/public/favicon.ico new file mode 100644 index 00000000..918ca54e Binary files /dev/null and b/demos/react-multi-client/public/favicon.ico differ diff --git a/demos/react-multi-client/public/website-widget.png b/demos/react-multi-client/public/website-widget.png new file mode 100644 index 00000000..1b70cb1b Binary files /dev/null and b/demos/react-multi-client/public/website-widget.png differ diff --git a/demos/react-multi-client/src/app/index.tsx b/demos/react-multi-client/src/app/index.tsx new file mode 100644 index 00000000..217f5358 --- /dev/null +++ b/demos/react-multi-client/src/app/index.tsx @@ -0,0 +1,18 @@ +import SupabaseProvider from '@/components/providers/SupabaseProvider'; +import { createRoot } from 'react-dom/client'; + +import Layout from './layout'; +import Page from './page'; + +const root = createRoot(document.getElementById('app')!); +root.render(); + +export function App() { + return ( + + + + + + ); +} diff --git a/demos/react-multi-client/src/app/layout.tsx b/demos/react-multi-client/src/app/layout.tsx new file mode 100644 index 00000000..b26ea7c4 --- /dev/null +++ b/demos/react-multi-client/src/app/layout.tsx @@ -0,0 +1,7 @@ +import { createIX2Engine, InteractionsProvider } from '@/devlink'; +import React from 'react'; +import '@/devlink/global.css'; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/demos/react-multi-client/src/app/page.tsx b/demos/react-multi-client/src/app/page.tsx new file mode 100644 index 00000000..2ed3061e --- /dev/null +++ b/demos/react-multi-client/src/app/page.tsx @@ -0,0 +1,23 @@ +import { SuspendableUserComponent } from '@/components/SuspendableUserComponent'; +import { WebWidgetFacade } from '@/components/facades/WebWidgetFacade'; +import SystemProvider from '@/components/providers/SystemProvider'; +import '@/styles/widget.css'; + +export default function EntryPage() { + return ( +
+ + + + } + userBSlot={ + + + + } + /> +
+ ); +} diff --git a/demos/react-multi-client/src/components/LogDisplayWidget.tsx b/demos/react-multi-client/src/components/LogDisplayWidget.tsx new file mode 100644 index 00000000..949f42cb --- /dev/null +++ b/demos/react-multi-client/src/components/LogDisplayWidget.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +export interface LogDisplayWidgetProps { + lines: string[]; +} + +export const LogDisplayWidget: React.FC = (props) => { + return ( +
+ {props.lines.map((log, i) => ( +

+ {log} +

+ ))} +
+ ); +}; diff --git a/demos/react-multi-client/src/components/PebbleBoxWidget.tsx b/demos/react-multi-client/src/components/PebbleBoxWidget.tsx new file mode 100644 index 00000000..a6b06ae6 --- /dev/null +++ b/demos/react-multi-client/src/components/PebbleBoxWidget.tsx @@ -0,0 +1,38 @@ +import { MAX_PEBBLES, PebbleDef, Shape, TABLE_NAME } from '@/definitions/Schema'; +import { useQuery } from '@powersync/react'; +import React from 'react'; +import { + DataCircleFacade, + DataDiamondFacade, + DataHexagonFacade, + DataPentagonFacade, + DataTriangleFacade +} from './facades/ShapeFacade'; + +const ShapeWidgetMap = { + [Shape.CIRCLE]: DataCircleFacade, + [Shape.HEXAGON]: DataHexagonFacade, + [Shape.PENTAGON]: DataPentagonFacade, + [Shape.DIAMOND]: DataDiamondFacade, + [Shape.TRIANGLE]: DataTriangleFacade +}; + +export const PebbleBoxWidget: React.FC = () => { + const { data: pebbles } = useQuery( + `SELECT * FROM ${TABLE_NAME} ORDER BY shape ASC LIMIT ${MAX_PEBBLES}`, + [] + ); + + return ( + <> + {pebbles.map((pebble) => { + const Widget = ShapeWidgetMap[pebble.shape]; + return ( +
+ +
+ ); + })} + + ); +}; diff --git a/demos/react-multi-client/src/components/SuspendableUserComponent.tsx b/demos/react-multi-client/src/components/SuspendableUserComponent.tsx new file mode 100644 index 00000000..a4336722 --- /dev/null +++ b/demos/react-multi-client/src/components/SuspendableUserComponent.tsx @@ -0,0 +1,42 @@ +import { UserComponent } from './UserComponent'; +import { useSystem } from './providers/SystemProvider'; +import { useState, useEffect } from 'react'; + +export interface UserComponentProps { + leftSide?: boolean; +} + +export const SuspendableUserComponent: React.FC = (props) => { + const connector = useSystem(); + + const [show, setShow] = useState(false); + + useEffect(() => { + if (!connector) { + console.error(`No Supabase connector has been created yet.`); + return; + } + + if (!connector.ready) { + const l = connector.registerListener({ + initialized: () => { + /** + * Redirect if on the entry view + */ + if (connector.ready) { + setShow(true); + } else { + setShow(false); + } + } + }); + return () => l?.(); + } + }, []); + + if (show) { + return ; + } + + return <>Loading; +}; diff --git a/demos/react-multi-client/src/components/UserComponent.tsx b/demos/react-multi-client/src/components/UserComponent.tsx new file mode 100644 index 00000000..752f207f --- /dev/null +++ b/demos/react-multi-client/src/components/UserComponent.tsx @@ -0,0 +1,229 @@ +import { PebbleBoxWidget } from '@/components/PebbleBoxWidget'; +import { useConsoleLines } from '@/components/hooks/useConsoleLines'; +import { useSystem, useTimedPowerSync } from '@/components/providers/SystemProvider'; +import { MAX_PEBBLES, NUM_INIT_PEBBLES, PebbleDef, TABLE_NAME, randomPebbleShape } from '@/definitions/Schema'; +import { useStatus } from '@powersync/react'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { LogDisplayWidget } from './LogDisplayWidget'; +import { UserFacade } from './facades/UserFacade'; + +export interface UserComponentProps { + leftSide?: boolean; +} + +const UPLOADING_CSS_CLASS = 'user__writes--active'; +const DOWNLOADING_CSS_CLASS = 'user__reads--active'; +const TOGGLE_ONLINE_CSS_CLASS = 'toggle-button-green--on button-toggle--on'; + +enum ConnectionText { + CONNECTING = 'Connecting...', + CONNECTED = 'Connected', + DISCONNECTED = 'Disconnected' +} + +enum CrudVerb { + CREATE = 'created', + UPDATE = 'updated', + DELETE = 'deleted' +} + +const buildLog = (verb: CrudVerb, elapsedMs: number, num = 1) => { + return `${num} row${num > 1 ? 's' : ''} ${verb}. Total time: ${elapsedMs} ms`; +}; + +export const UserComponent: React.FC = (props) => { + // Wraps all operations against the powersync instance with a timer + const powersync = useTimedPowerSync(); + const connector = useSystem(); + const status = useStatus(); + const userID = connector.currentSession?.user.id; + + const [connecting, setConnecting] = React.useState(true); + const localStorageKey = useMemo(() => `${powersync.localKey}`, []); + + const { logs, addLog } = useConsoleLines(); + + useEffect(() => { + const onStorage = async (e: StorageEvent) => { + // Only listen to events from this user side + if (e.key === localStorageKey) { + // We toggle the localStorage key to trigger the event from another tab + // ann then check if we need to also connect + if (!!e.oldValue && !e.newValue) { + if (!connecting && !status.connected) { + setConnecting(true); + await powersync.connect(connector); + } + } + } + }; + + window.addEventListener('storage', onStorage); + return () => { + window.removeEventListener('storage', onStorage); + }; + }, [connecting, status.connected]); + + useEffect(() => { + if (!connecting) { + return; + } + addLog(ConnectionText.CONNECTING); + }, [connecting]); + + useEffect(() => { + setConnecting(false); + if (status.connected) { + addLog(ConnectionText.CONNECTED, true); + } else if (!connecting) { + addLog(ConnectionText.DISCONNECTED, false); + } + }, [status.connected]); + + useEffect(() => { + (async () => { + if (props.leftSide) { + const isInitialized = await powersync.getOptional('SELECT * FROM settings WHERE initialized = ?', [1]); + if (!!isInitialized) { + console.log('Already initialized'); + return; + } + // Only clear and init DB if we have not been initialized yet + await powersync.execute(`DELETE FROM ${TABLE_NAME}`); + await powersync.writeTransaction(async (tx) => { + for (let i = 0; i < NUM_INIT_PEBBLES; i++) { + await tx.execute(`INSERT INTO ${TABLE_NAME} (id, user_id, shape) VALUES (uuid(), ?, ?)`, [ + userID, + randomPebbleShape() + ]); + } + await tx.execute('INSERT into settings (id, initialized) VALUES (uuid(), ?)', [1]); + }); + } + })(); + }, [userID]); + + const checkMaxPebbles = async () => { + // Deletes any pebble if we have more than MAX_PEBBLES, + // this is caused by creating pebbles while pebbles are added to the internal DB by PowerSync sync + await powersync.execute(`DELETE FROM ${TABLE_NAME} + WHERE id NOT IN ( + SELECT id + FROM ${TABLE_NAME} + ORDER BY shape ASC + LIMIT ${MAX_PEBBLES} + )`); + }; + + const handleTelemetry = useCallback( + async (operation: string) => { + await powersync.execute( + `INSERT INTO operations (id, created_at, user_id, operation) VALUES (uuid(), datetime(), ?, ?)`, + [userID, operation] + ); + }, + [userID] + ); + + const handleCreate = useCallback(async () => { + const { count } = await powersync.get<{ count: number }>(`SELECT COUNT(*) as count FROM ${TABLE_NAME}`, []); + + if (count >= MAX_PEBBLES) { + return; + } + + const { elapsedTime } = await powersync.timedExecute( + `INSERT INTO ${TABLE_NAME} (id, created_at, user_id, shape) VALUES (uuid(), datetime(), ?, ?)`, + [userID, randomPebbleShape()] + ); + + handleTelemetry(CrudVerb.CREATE); + addLog(buildLog(CrudVerb.CREATE, elapsedTime)); + }, [userID]); + + const handleUpdate = useCallback(async () => { + await checkMaxPebbles(); + const setOfPebbles = await powersync.getAll( + `SELECT id FROM ${TABLE_NAME} ORDER BY RANDOM() LIMIT 3`, + [] + ); + + if (setOfPebbles.length == 0) { + return; + } + const { elapsedTime } = await powersync.timedWriteTransaction(async (tx) => { + setOfPebbles.forEach((pebble) => { + tx.execute(`UPDATE ${TABLE_NAME} SET shape = ? WHERE id = ?`, [randomPebbleShape(), pebble.id]); + }); + }); + + handleTelemetry(CrudVerb.UPDATE); + addLog(buildLog(CrudVerb.UPDATE, elapsedTime, setOfPebbles.length)); + }, [userID]); + + const handleDelete = useCallback(async () => { + await checkMaxPebbles(); + + const { elapsedTime } = await powersync.timedExecute( + // Delete the right-most pebble ordered by shape + ` + DELETE FROM ${TABLE_NAME} + WHERE id = ( + SELECT id + FROM ${TABLE_NAME} + ORDER BY shape DESC + LIMIT 1 + )` + ); + + handleTelemetry(CrudVerb.DELETE); + addLog(buildLog(CrudVerb.DELETE, elapsedTime)); + }, [userID]); + + const toggleOnline = useCallback(async () => { + if (connecting) { + return; + } + if (status.connected) { + await powersync.disconnect(); + } else { + setConnecting(true); + await powersync.connect(connector); + } + }, [status.connected, connecting]); + + if (!userID) { + return null; + } + + const showOnline = status.connected || connecting; + + const className = useMemo(() => { + return [ + status.dataFlowStatus.downloading ? DOWNLOADING_CSS_CLASS : '', + status.dataFlowStatus.uploading ? UPLOADING_CSS_CLASS : '', + showOnline ? TOGGLE_ONLINE_CSS_CLASS : '' + ] + .join(' ') + .trim(); + }, [status.dataFlowStatus.downloading, status.dataFlowStatus.uploading, showOnline]); + + return ( +
+ } + logText={} + onlineOfflineToggle={{ onClick: () => toggleOnline() }} + buttonCreate={{ onClick: () => handleCreate() }} + buttonUpdate={{ onClick: () => handleUpdate() }} + buttonDelete={{ onClick: () => handleDelete() }} + /> +
+ ); +}; diff --git a/demos/react-multi-client/src/components/facades/ShapeFacade.tsx b/demos/react-multi-client/src/components/facades/ShapeFacade.tsx new file mode 100644 index 00000000..adfe3147 --- /dev/null +++ b/demos/react-multi-client/src/components/facades/ShapeFacade.tsx @@ -0,0 +1,24 @@ +import { DataCircle, DataDiamond, DataHexagon, DataPentagon, DataTriangle } from '@/devlink'; + +// To drop the devlink dependency, you can use the following code: +// export const DataCircleFacade = () => ; +export const DataCircleFacade = () => ; + +export const DataHexagonFacade = () => ; +export const DataPentagonFacade = () => ; +export const DataDiamondFacade = () => ; +export const DataTriangleFacade = () => ; + +const AlternativeCircle: React.FC = () => { + const circleStyle = { + width: '48px', + height: '48px', + borderRadius: '50%', + backgroundColor: 'yellow', + display: 'flex', + alignItems: 'center', + justifyContent: 'center' + }; + + return
; +}; diff --git a/demos/react-multi-client/src/components/facades/UserFacade.tsx b/demos/react-multi-client/src/components/facades/UserFacade.tsx new file mode 100644 index 00000000..1429eece --- /dev/null +++ b/demos/react-multi-client/src/components/facades/UserFacade.tsx @@ -0,0 +1,31 @@ +import { UserA, UserB } from '@/devlink'; +import React from 'react'; + +export type OpenUserProps = Parameters[0] & { leftSide?: boolean }; + +type EventHandler = { onClick: () => void }; + +export type UserProps = { + online?: boolean; + offline?: boolean; + content?: React.ReactNode; + buttonCreate?: EventHandler; + buttonUpdate?: EventHandler; + buttonDelete?: EventHandler; + logText?: React.ReactNode; + writesFalse?: boolean; + writesTrue?: boolean; + writePath?: EventHandler; + readsFalse?: boolean; + readsTrue?: boolean; + readPath?: EventHandler; + onlineSync?: boolean; + onlineOfflineToggle?: EventHandler; + leftSide?: boolean; +}; + +export const UserFacade: React.FC = (props) => { + // To drop the devlink dependency, you can introduce your own "user" component that uses the `UserProps`. + const User = props.leftSide ? UserA : UserB; + return ; +}; diff --git a/demos/react-multi-client/src/components/facades/WebWidgetFacade.tsx b/demos/react-multi-client/src/components/facades/WebWidgetFacade.tsx new file mode 100644 index 00000000..c704aa38 --- /dev/null +++ b/demos/react-multi-client/src/components/facades/WebWidgetFacade.tsx @@ -0,0 +1,27 @@ +import { WebDemoWidget } from '@/devlink'; +import React from 'react'; + +export type WebWidgetProps = { + userASlot?: React.ReactNode; + userBSlot?: React.ReactNode; +}; + +export const WebWidgetFacade: React.FC = (props) => { + const a = { props }; + + return ; + + // To drop the devlink dependency, you can use the following code: + // return ; +}; + +const Alternative: React.FC = (props) => { + const slotA = props.userASlot; + const slotB = props.userBSlot; + return ( +
+ {slotA ? slotA : <>A Not available} + {slotB ? slotB : <>B Not available} +
+ ); +}; diff --git a/demos/react-multi-client/src/components/hooks/useConsoleLines.ts b/demos/react-multi-client/src/components/hooks/useConsoleLines.ts new file mode 100644 index 00000000..a81ff91a --- /dev/null +++ b/demos/react-multi-client/src/components/hooks/useConsoleLines.ts @@ -0,0 +1,22 @@ +import { useState, useCallback } from 'react'; + +export interface ConsoleLineProps { + numLine?: number; + initialLogs?: string[]; +} +export function useConsoleLines(props: ConsoleLineProps = {}) { + const { initialLogs, numLine = 3 } = props; + const [logs, setLogs] = useState(initialLogs ?? []); + + const addLog = useCallback( + (log: string, clear = false) => { + if (clear) { + setLogs([log]); + return; + } + setLogs((prevLogs) => [...prevLogs, log].slice(-numLine)); + }, + [numLine] + ); + return { logs, addLog }; +} diff --git a/demos/react-multi-client/src/components/providers/SupabaseProvider.tsx b/demos/react-multi-client/src/components/providers/SupabaseProvider.tsx new file mode 100644 index 00000000..ecfc5953 --- /dev/null +++ b/demos/react-multi-client/src/components/providers/SupabaseProvider.tsx @@ -0,0 +1,22 @@ +import { createClient, SupabaseClient } from '@supabase/supabase-js'; +import React, { PropsWithChildren, useState } from 'react'; + +const SupabaseContext = React.createContext<{ client: SupabaseClient }>({} as any); +export const useSupabase = () => React.useContext(SupabaseContext); + +const SupabaseProvider: React.FC = (props) => { + const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; + const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; + + const [client] = useState(() => + createClient(supabaseUrl, supabaseAnonKey, { + auth: { + persistSession: true + } + }) + ); + + return {props.children}; +}; + +export default SupabaseProvider; diff --git a/demos/react-multi-client/src/components/providers/SystemProvider.tsx b/demos/react-multi-client/src/components/providers/SystemProvider.tsx new file mode 100644 index 00000000..21f2f244 --- /dev/null +++ b/demos/react-multi-client/src/components/providers/SystemProvider.tsx @@ -0,0 +1,57 @@ +'use client'; +import { TimedPowerSyncDBFactory } from '@/library/PowerSyncDBFactory'; +import { TimedPowerSyncDatabase } from '@/library/TimedPowerSyncDatabase'; +import React, { PropsWithChildren } from 'react'; +import { PowerSyncContext, usePowerSync as _usePowerSync } from '@powersync/react'; +import { AppSchema } from '@/definitions/Schema'; +import { SupabaseConnector } from '@/library/SupabaseConnector'; +import { useSupabase } from './SupabaseProvider'; +import Logger from 'js-logger'; + +Logger.useDefaults(); +Logger.setLevel(Logger.DEBUG); + +export interface SystemProviderProps { + dbFilename: string; +} + +const SystemProvider: React.FC> = (props) => { + const { client } = useSupabase(); + + const [connector] = React.useState(new SupabaseConnector(client)); + + const [powersync] = React.useState( + new TimedPowerSyncDBFactory({ + dbFilename: props.dbFilename, + schema: AppSchema, + flags: { + disableSSRWarning: false + } + }).getInstance() + ); + + React.useEffect(() => { + powersync.init(); + + const l = connector.registerListener({ + initialized: () => {}, + sessionStarted: async () => { + await powersync.connect(connector); + } + }); + connector.init(); + }, [powersync, connector]); + + return ( + + {props.children} + + ); +}; + +export default SystemProvider; + +export const SystemContext = React.createContext(null as any); +export const useSystem = () => React.useContext(SystemContext); + +export const useTimedPowerSync = () => _usePowerSync() as unknown as TimedPowerSyncDatabase; diff --git a/demos/react-multi-client/src/definitions/Schema.ts b/demos/react-multi-client/src/definitions/Schema.ts new file mode 100644 index 00000000..748a0501 --- /dev/null +++ b/demos/react-multi-client/src/definitions/Schema.ts @@ -0,0 +1,49 @@ +import { Column, ColumnType, Schema, Table } from '@powersync/web'; + +export const TABLE_NAME = 'pebbles'; +export const MAX_PEBBLES = 5; +export const NUM_INIT_PEBBLES = 3; + +export enum Shape { + HEXAGON = 'hexagon', + CIRCLE = 'circle', + DIAMOND = 'diamond', + TRIANGLE = 'triangle', + PENTAGON = 'pentagon' +} + +export interface PebbleDef { + id: string; + shape: Shape; + created_at: string; + user_id: string; +} + +export const AppSchema = new Schema([ + new Table({ + name: TABLE_NAME, + columns: [ + new Column({ name: 'shape', type: ColumnType.TEXT }), + new Column({ name: 'created_at', type: ColumnType.TEXT }), + new Column({ name: 'user_id', type: ColumnType.TEXT }) + ] + }), + new Table({ + name: 'operations', + columns: [ + new Column({ name: 'operation', type: ColumnType.TEXT }), + new Column({ name: 'created_at', type: ColumnType.TEXT }), + new Column({ name: 'user_id', type: ColumnType.TEXT }) + ] + }), + new Table({ + name: 'settings', + localOnly: true, + columns: [new Column({ name: 'initialized', type: ColumnType.INTEGER })] + }) +]); + +export function randomPebbleShape(): Shape { + const colors = Object.values(Shape); + return colors[Math.floor(Math.random() * colors.length)]; +} diff --git a/demos/react-multi-client/src/devlink/DataCircle.d.ts b/demos/react-multi-client/src/devlink/DataCircle.d.ts new file mode 100644 index 00000000..98389021 --- /dev/null +++ b/demos/react-multi-client/src/devlink/DataCircle.d.ts @@ -0,0 +1,7 @@ +import * as React from "react"; +import * as Types from "./types"; + +declare function DataCircle(props: { + as?: React.ElementType; + circleProps?: Types.Devlink.RuntimeProps; +}): React.JSX.Element; diff --git a/demos/react-multi-client/src/devlink/DataCircle.jsx b/demos/react-multi-client/src/devlink/DataCircle.jsx new file mode 100644 index 00000000..e95b2c46 --- /dev/null +++ b/demos/react-multi-client/src/devlink/DataCircle.jsx @@ -0,0 +1,20 @@ +import React from "react"; +import * as _Builtin from "./_Builtin"; + +export function DataCircle({ + as: _Component = _Builtin.Image, + circleProps = {}, +}) { + return ( + <_Component + className="pebble-img" + id="w-node-f733fe07-6748-de25-1fff-855e68b9c0b2-68b9c0b2" + loading="lazy" + width="auto" + height="auto" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655f57b904865e6f8cb24022_icon-widget-circle-large.svg" + {...circleProps} + /> + ); +} diff --git a/demos/react-multi-client/src/devlink/DataDiamond.d.ts b/demos/react-multi-client/src/devlink/DataDiamond.d.ts new file mode 100644 index 00000000..9c8a8339 --- /dev/null +++ b/demos/react-multi-client/src/devlink/DataDiamond.d.ts @@ -0,0 +1,7 @@ +import * as React from "react"; +import * as Types from "./types"; + +declare function DataDiamond(props: { + as?: React.ElementType; + diamondProps?: Types.Devlink.RuntimeProps; +}): React.JSX.Element; diff --git a/demos/react-multi-client/src/devlink/DataDiamond.jsx b/demos/react-multi-client/src/devlink/DataDiamond.jsx new file mode 100644 index 00000000..4b1054ec --- /dev/null +++ b/demos/react-multi-client/src/devlink/DataDiamond.jsx @@ -0,0 +1,20 @@ +import React from "react"; +import * as _Builtin from "./_Builtin"; + +export function DataDiamond({ + as: _Component = _Builtin.Image, + diamondProps = {}, +}) { + return ( + <_Component + className="pebble-img" + id="w-node-b7eff2b8-4a26-c330-2df6-f9add34f1f97-d34f1f97" + loading="lazy" + width="auto" + height="auto" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655f57b9e62706ef1d8fcba1_icon-widget-square-large.svg" + {...diamondProps} + /> + ); +} diff --git a/demos/react-multi-client/src/devlink/DataHexagon.d.ts b/demos/react-multi-client/src/devlink/DataHexagon.d.ts new file mode 100644 index 00000000..6c9256ad --- /dev/null +++ b/demos/react-multi-client/src/devlink/DataHexagon.d.ts @@ -0,0 +1,7 @@ +import * as React from "react"; +import * as Types from "./types"; + +declare function DataHexagon(props: { + as?: React.ElementType; + hexProps?: Types.Devlink.RuntimeProps; +}): React.JSX.Element; diff --git a/demos/react-multi-client/src/devlink/DataHexagon.jsx b/demos/react-multi-client/src/devlink/DataHexagon.jsx new file mode 100644 index 00000000..451921a1 --- /dev/null +++ b/demos/react-multi-client/src/devlink/DataHexagon.jsx @@ -0,0 +1,20 @@ +import React from "react"; +import * as _Builtin from "./_Builtin"; + +export function DataHexagon({ + as: _Component = _Builtin.Image, + hexProps = {}, +}) { + return ( + <_Component + className="pebble-img" + id="w-node-_80e80cb2-814d-8f56-98ee-8821f7776ee3-f7776ee3" + loading="lazy" + width="auto" + height="auto" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655f57b966f71e6a07b0124f_icon-widget-hexagon-large.svg" + {...hexProps} + /> + ); +} diff --git a/demos/react-multi-client/src/devlink/DataPentagon.d.ts b/demos/react-multi-client/src/devlink/DataPentagon.d.ts new file mode 100644 index 00000000..bcccab94 --- /dev/null +++ b/demos/react-multi-client/src/devlink/DataPentagon.d.ts @@ -0,0 +1,7 @@ +import * as React from "react"; +import * as Types from "./types"; + +declare function DataPentagon(props: { + as?: React.ElementType; + pentaProps?: Types.Devlink.RuntimeProps; +}): React.JSX.Element; diff --git a/demos/react-multi-client/src/devlink/DataPentagon.jsx b/demos/react-multi-client/src/devlink/DataPentagon.jsx new file mode 100644 index 00000000..abc1fb6d --- /dev/null +++ b/demos/react-multi-client/src/devlink/DataPentagon.jsx @@ -0,0 +1,20 @@ +import React from "react"; +import * as _Builtin from "./_Builtin"; + +export function DataPentagon({ + as: _Component = _Builtin.Image, + pentaProps = {}, +}) { + return ( + <_Component + className="pebble-img" + id="w-node-_5dd15a21-ce16-bb6d-fe9d-0bbd0859cdd9-0859cdd9" + loading="lazy" + width="auto" + height="auto" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655f57b9f18bd001f68a31e2_icon-widget-pentagon-large.svg" + {...pentaProps} + /> + ); +} diff --git a/demos/react-multi-client/src/devlink/DataTriangle.d.ts b/demos/react-multi-client/src/devlink/DataTriangle.d.ts new file mode 100644 index 00000000..97f1d04e --- /dev/null +++ b/demos/react-multi-client/src/devlink/DataTriangle.d.ts @@ -0,0 +1,7 @@ +import * as React from "react"; +import * as Types from "./types"; + +declare function DataTriangle(props: { + as?: React.ElementType; + triProps?: Types.Devlink.RuntimeProps; +}): React.JSX.Element; diff --git a/demos/react-multi-client/src/devlink/DataTriangle.jsx b/demos/react-multi-client/src/devlink/DataTriangle.jsx new file mode 100644 index 00000000..b2abe014 --- /dev/null +++ b/demos/react-multi-client/src/devlink/DataTriangle.jsx @@ -0,0 +1,20 @@ +import React from "react"; +import * as _Builtin from "./_Builtin"; + +export function DataTriangle({ + as: _Component = _Builtin.Image, + triProps = {}, +}) { + return ( + <_Component + className="pebble-img" + id="w-node-d5343058-cbeb-946e-46c7-1933f7b6dcd6-f7b6dcd6" + loading="lazy" + width="auto" + height="auto" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655f57b99d657cf4c47a85cb_icon-widget-triangle-large.svg" + {...triProps} + /> + ); +} diff --git a/demos/react-multi-client/src/devlink/PsFooter.d.ts b/demos/react-multi-client/src/devlink/PsFooter.d.ts new file mode 100644 index 00000000..a756df63 --- /dev/null +++ b/demos/react-multi-client/src/devlink/PsFooter.d.ts @@ -0,0 +1,3 @@ +import * as React from "react"; + +declare function PsFooter(props: { as?: React.ElementType }): React.JSX.Element; diff --git a/demos/react-multi-client/src/devlink/PsFooter.jsx b/demos/react-multi-client/src/devlink/PsFooter.jsx new file mode 100644 index 00000000..4ae447bf --- /dev/null +++ b/demos/react-multi-client/src/devlink/PsFooter.jsx @@ -0,0 +1,304 @@ +import React from "react"; +import * as _Builtin from "./_Builtin"; + +export function PsFooter({ as: _Component = _Builtin.Block }) { + return ( + <_Component className="ps-footer-div" tag="div"> + <_Builtin.Section + className="ps-footer" + grid={{ + type: "section", + }} + tag="section" + > + <_Builtin.BlockContainer + className="ps-footer-container" + grid={{ + type: "container", + }} + tag="div" + > + <_Builtin.Row + className="ps-footer-layout" + tag="div" + columns={{ + main: "6|3|3", + medium: "", + small: "4|4|4", + tiny: "", + }} + > + <_Builtin.Column className="ps-footer-column-logo" tag="div"> + <_Builtin.Image + className="ps-footer-logo" + loading="lazy" + width="auto" + height="auto" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655cae9c85d542976fbd4b9a_powersync-logo-color.svg" + /> + + <_Builtin.Column className="ps-footer-column-dev" tag="div"> + <_Builtin.Heading className="ps-footer-column-heading" tag="h6"> + {"Developers"} + + <_Builtin.Link + className="ps-footer-text-link" + button={false} + block="" + options={{ + href: "https://docs.powersync.com/", + target: "_blank", + }} + > + {"Docs"} + + <_Builtin.Link + className="ps-footer-text-link" + button={false} + block="" + options={{ + href: "https://docs.powersync.com/quickstart-guide", + target: "_blank", + }} + > + {"Quickstart Guide"} + + <_Builtin.Link + className="ps-footer-text-link" + button={false} + block="" + options={{ + href: "https://docs.powersync.com/installation/sync-rules", + target: "_blank", + }} + > + {"Sync Rules"} + + <_Builtin.Link + className="ps-footer-text-link" + button={false} + block="" + options={{ + href: "https://docs.powersync.com/api-reference", + target: "_blank", + }} + > + {"APIReference"} + + <_Builtin.Link + className="ps-footer-text-link" + button={false} + block="" + options={{ + href: "#", + }} + > + {"Open-Source Packages"} + + <_Builtin.Link + className="ps-footer-text-link" + button={false} + block="" + options={{ + href: "https://releases.powersync.co/", + target: "_blank", + }} + > + {"Release Notes"} + + <_Builtin.Link + className="ps-footer-text-link" + button={false} + block="" + options={{ + href: "https://roadmap.powersync.com/", + target: "_blank", + }} + > + {"Roadmap"} + + + <_Builtin.Column className="ps-footer-column-about" tag="div"> + <_Builtin.Heading className="ps-footer-column-heading" tag="h6"> + {"About"} + + <_Builtin.Link + className="ps-footer-text-link" + button={false} + block="" + options={{ + href: "#", + }} + > + {"Blog"} + + <_Builtin.Link + className="ps-footer-text-link" + button={false} + block="" + options={{ + href: "#", + }} + > + {"Pricing"} + + <_Builtin.Link + className="ps-footer-text-link" + button={false} + block="" + options={{ + href: "#", + }} + > + {"Company"} + + <_Builtin.Link + className="ps-footer-text-link" + button={false} + block="" + options={{ + href: "https://docs.powersync.co/resources/security", + target: "_blank", + }} + > + {"Security"} + + <_Builtin.Link + className="ps-footer-text-link" + button={false} + block="" + options={{ + href: "#", + target: "_blank", + }} + > + {"Terms of Service"} + + <_Builtin.Link + className="ps-footer-text-link" + button={false} + block="" + options={{ + href: "#", + target: "_blank", + }} + > + {"Acceptable Use Policy"} + + <_Builtin.Link + className="ps-footer-text-link" + button={false} + block="" + options={{ + href: "#", + target: "_blank", + }} + > + {"Privacy Policy"} + + + + <_Builtin.Block className="ps-footer-fineprint-bottom" tag="div"> + <_Builtin.Block className="ps-footer-div-bottom-text" tag="div"> + {"© 2023 Journey Mobile, Inc."} + + <_Builtin.Block className="ps-footer-bottom-div-links" tag="div"> + <_Builtin.Link + className="ps-footer-bottom-link" + button={false} + block="inline" + options={{ + href: "https://github.com/powersync-ja", + target: "_blank", + }} + > + <_Builtin.Image + className="ps-footer-link-icon" + loading="lazy" + width="auto" + height="24" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655cae9c85d542976fbd4b48_github-white.svg" + /> + + <_Builtin.Link + className="ps-footer-bottom-link" + button={false} + block="inline" + options={{ + href: "https://discord.gg/powersync", + target: "_blank", + }} + > + <_Builtin.Image + className="ps-footer-link-icon" + loading="lazy" + width="auto" + height="24" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655cae9c85d542976fbd4b4d_discord-white.svg" + /> + + <_Builtin.Link + className="ps-footer-bottom-link" + button={false} + block="inline" + options={{ + href: "https://twitter.com/powersync_", + target: "_blank", + }} + > + <_Builtin.Image + className="ps-footer-link-icon" + loading="lazy" + width="auto" + height="20" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655cae9c85d542976fbd4ba3_x-white.svg" + /> + + <_Builtin.Link + className="ps-footer-bottom-link" + button={false} + block="inline" + options={{ + href: "https://www.youtube.com/@powersync_", + target: "_blank", + }} + > + <_Builtin.Image + className="ps-footer-link-icon" + loading="lazy" + width="32" + height="24" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655cae9c85d542976fbd4b80_youtube-white.svg" + /> + + <_Builtin.Link + className="ps-footer-bottom-link" + button={false} + block="inline" + options={{ + href: "https://www.linkedin.com/showcase/journeyapps-powersync/", + target: "_blank", + }} + > + <_Builtin.Image + className="ps-footer-link-icon" + loading="lazy" + width="auto" + height="24" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655cae9c85d542976fbd4ba6_linkedin-icon-white.svg" + /> + + + + + + <_Builtin.Block className="ps-footer-div-bottom-banner" tag="div" /> + + ); +} diff --git a/demos/react-multi-client/src/devlink/PsNavbar.d.ts b/demos/react-multi-client/src/devlink/PsNavbar.d.ts new file mode 100644 index 00000000..3a6b2f44 --- /dev/null +++ b/demos/react-multi-client/src/devlink/PsNavbar.d.ts @@ -0,0 +1,3 @@ +import * as React from "react"; + +declare function PsNavbar(props: { as?: React.ElementType }): React.JSX.Element; diff --git a/demos/react-multi-client/src/devlink/PsNavbar.jsx b/demos/react-multi-client/src/devlink/PsNavbar.jsx new file mode 100644 index 00000000..5231d946 --- /dev/null +++ b/demos/react-multi-client/src/devlink/PsNavbar.jsx @@ -0,0 +1,185 @@ +import React from "react"; +import * as _Builtin from "./_Builtin"; + +export function PsNavbar({ as: _Component = _Builtin.NavbarWrapper }) { + return ( + <_Component + className="ps-navbar visible-lg" + tag="div" + config={{ + animation: "default", + collapse: "medium", + docHeight: true, + duration: 400, + easing: "ease", + easing2: "ease", + noScroll: false, + }} + > + <_Builtin.NavbarContainer className="ps-container-navbar" tag="header"> + <_Builtin.NavbarBrand + className="ps-nav-logo" + options={{ + href: "#", + }} + > + <_Builtin.Image + className="ps-logo" + width="auto" + height="auto" + loading="lazy" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655cae9c85d542976fbd4b49_powersync-logo-horizontal-all-white.svg" + /> + + <_Builtin.Block className="ps-nav-links-div w-clearfix" tag="div"> + <_Builtin.NavbarMenu + className="ps-nav-menu-center" + tag="nav" + role="navigation" + > + <_Builtin.NavbarLink + className="ps-nav-link plausible-event-name--button-click-docs" + id="button-click-docs" + options={{ + href: "https://docs.powersync.com/", + target: "_blank", + }} + > + {"Docs"} + + <_Builtin.NavbarLink + className="ps-nav-link" + options={{ + href: "#", + }} + > + {"Open-Source"} + + <_Builtin.NavbarLink + className="ps-nav-link" + options={{ + href: "#", + }} + > + {"Blog"} + + <_Builtin.NavbarLink + className="ps-nav-link" + options={{ + href: "#", + }} + > + {"Pricing"} + + + <_Builtin.NavbarMenu + className="ps-nav-menu-right" + tag="nav" + role="navigation" + > + <_Builtin.Link + className="ps-nav-link-devcom" + button={false} + block="inline" + options={{ + href: "https://github.com/powersync-ja", + target: "_blank", + }} + > + <_Builtin.Image + className="ps-nav-link-icon" + width="auto" + height="24" + loading="lazy" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655cae9c85d542976fbd4b48_github-white.svg" + /> + + <_Builtin.Link + className="ps-nav-link-devcom" + button={false} + block="inline" + options={{ + href: "https://discord.gg/powersync", + target: "_blank", + }} + > + <_Builtin.Image + className="ps-nav-link-icon" + width="auto" + height="24" + loading="lazy" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655cae9c85d542976fbd4b4d_discord-white.svg" + /> + + <_Builtin.Link + className="ps-nav-link-devcom" + button={false} + block="inline" + options={{ + href: "https://twitter.com/powersync_", + target: "_blank", + }} + > + <_Builtin.Image + className="ps-nav-link-icon" + width="auto" + height="20" + loading="lazy" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655cae9c85d542976fbd4bc1_x-white.svg" + /> + + <_Builtin.Link + className="ps-nav-link-devcom" + button={false} + block="inline" + options={{ + href: "https://www.youtube.com/@powersync_", + target: "_blank", + }} + > + <_Builtin.Image + className="ps-nav-link-icon" + width="32" + height="24" + loading="lazy" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655cae9c85d542976fbd4b80_youtube-white.svg" + /> + + <_Builtin.NavbarLink + className="ps-nav-link ps-nav-button ps-nav-sign-in-button plausible-event-name--button-click-sign-in" + id="button-click-sign-in" + options={{ + href: "https://powersync.journeyapps.com/", + }} + > + {"Sign in"} + + <_Builtin.NavbarLink + className="ps-nav-link ps-nav-button plausible-event-name--button-click-get-started" + id="button-click-get-started" + options={{ + href: "https://accounts.journeyapps.com/portal/free-trial?powersync=true", + }} + > + {"Get started"} + + + + <_Builtin.NavbarButton className="menu-button" tag="div"> + <_Builtin.Icon + className="icon" + widget={{ + type: "icon", + icon: "nav-menu", + }} + /> + + + + ); +} diff --git a/demos/react-multi-client/src/devlink/PsNavbarMobile.d.ts b/demos/react-multi-client/src/devlink/PsNavbarMobile.d.ts new file mode 100644 index 00000000..8b979afd --- /dev/null +++ b/demos/react-multi-client/src/devlink/PsNavbarMobile.d.ts @@ -0,0 +1,5 @@ +import * as React from "react"; + +declare function PsNavbarMobile(props: { + as?: React.ElementType; +}): React.JSX.Element; diff --git a/demos/react-multi-client/src/devlink/PsNavbarMobile.jsx b/demos/react-multi-client/src/devlink/PsNavbarMobile.jsx new file mode 100644 index 00000000..4707db02 --- /dev/null +++ b/demos/react-multi-client/src/devlink/PsNavbarMobile.jsx @@ -0,0 +1,131 @@ +import React from "react"; +import * as _Builtin from "./_Builtin"; + +export function PsNavbarMobile({ as: _Component = _Builtin.NavbarWrapper }) { + return ( + <_Component + className="ps-navbar visible-sm" + tag="div" + config={{ + animation: "default", + collapse: "medium", + docHeight: true, + duration: 400, + easing: "ease", + easing2: "ease", + noScroll: false, + }} + > + <_Builtin.NavbarContainer className="ps-container-navbar" tag="div"> + <_Builtin.NavbarBrand + className="ps-nav-logo" + options={{ + href: "#", + }} + > + <_Builtin.Image + className="ps-logo" + width="auto" + height="auto" + loading="lazy" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655cae9c85d542976fbd4b49_powersync-logo-horizontal-all-white.svg" + /> + + <_Builtin.NavbarMenu + className="ps-nav-menu-center" + tag="nav" + role="navigation" + > + <_Builtin.NavbarLink + className="ps-nav-link" + options={{ + href: "https://docs.powersync.co/", + target: "_blank", + }} + > + {"Docs"} + + <_Builtin.NavbarLink + className="ps-nav-link" + options={{ + href: "#", + }} + > + {"Open-Source"} + + <_Builtin.NavbarLink + className="ps-nav-link" + options={{ + href: "#", + }} + > + {"Blog"} + + <_Builtin.NavbarLink + className="ps-nav-link" + options={{ + href: "#", + }} + > + {"Pricing"} + + <_Builtin.NavbarLink + className="ps-nav-link plausible-event-name--get-started" + id="button-click-get-started" + options={{ + href: "https://accounts.journeyapps.com/portal/free-trial?powersync=true", + target: "_blank", + }} + > + {"Get started"} + + <_Builtin.NavbarLink + className="ps-nav-link" + options={{ + href: "https://github.com/powersync-ja", + target: "_blank", + }} + > + {"GitHub"} + + <_Builtin.NavbarLink + className="ps-nav-link" + options={{ + href: "#", + target: "_blank", + }} + > + {"Discord"} + + <_Builtin.NavbarLink + className="ps-nav-link" + options={{ + href: "https://twitter.com/powersync_", + target: "_blank", + }} + > + {"Twitter"} + + <_Builtin.NavbarLink + className="ps-nav-link" + options={{ + href: "https://www.youtube.com/channel/UCSDdZvrZuizmc2EMBuTs2Qg", + target: "_blank", + }} + > + {"YouTube"} + + + <_Builtin.NavbarButton className="menu-button" tag="div"> + <_Builtin.Icon + widget={{ + type: "icon", + icon: "nav-menu", + }} + /> + + + + ); +} diff --git a/demos/react-multi-client/src/devlink/UserA.d.ts b/demos/react-multi-client/src/devlink/UserA.d.ts new file mode 100644 index 00000000..2da94d30 --- /dev/null +++ b/demos/react-multi-client/src/devlink/UserA.d.ts @@ -0,0 +1,22 @@ +import * as React from "react"; +import * as Types from "./types"; + +declare function UserA(props: { + as?: React.ElementType; + userASlot?: Types.Devlink.Slot; + online?: Types.Visibility.VisibilityConditions; + offline?: Types.Visibility.VisibilityConditions; + content?: Types.Devlink.Slot; + buttonCreate?: Types.Devlink.RuntimeProps; + buttonUpdate?: Types.Devlink.RuntimeProps; + buttonDelete?: Types.Devlink.RuntimeProps; + logText?: React.ReactNode; + writesFalse?: Types.Visibility.VisibilityConditions; + writesTrue?: Types.Visibility.VisibilityConditions; + writePath?: Types.Devlink.RuntimeProps; + readsFalse?: Types.Visibility.VisibilityConditions; + readsTrue?: Types.Visibility.VisibilityConditions; + readPath?: Types.Devlink.RuntimeProps; + onlineSync?: Types.Visibility.VisibilityConditions; + onlineOfflineToggle?: Types.Devlink.RuntimeProps; +}): React.JSX.Element; diff --git a/demos/react-multi-client/src/devlink/UserA.jsx b/demos/react-multi-client/src/devlink/UserA.jsx new file mode 100644 index 00000000..b01930ec --- /dev/null +++ b/demos/react-multi-client/src/devlink/UserA.jsx @@ -0,0 +1,230 @@ +import React from "react"; +import * as _Builtin from "./_Builtin"; +import { DataPentagon } from "./DataPentagon"; + +export function UserA({ + as: _Component = _Builtin.HFlex, + userASlot, + online, + offline = false, + content, + buttonCreate = {}, + buttonUpdate = {}, + buttonDelete = {}, + logText = ( + <> + {"1 row created. Total time: 5 ms"} +
+ {"1 row uploaded. Total time: 124 ms"} + + ), + writesFalse, + writesTrue = false, + writePath, + readsFalse, + readsTrue = false, + readPath, + onlineSync = false, + onlineOfflineToggle, +}) { + return ( + <_Component className="user-a-slot" tag="div"> + {userASlot ?? ( + <_Builtin.HFlex className="widget-h-flex" tag="div"> + <_Builtin.VFlex className="widget-v-flex" tag="div"> + <_Builtin.Block className="widget-user-div user-a" tag="div"> + <_Builtin.Block className="user-topbar-div" tag="div"> + <_Builtin.Block className="user-topbar-label user-a" tag="div"> + {"User A"} + + <_Builtin.HFlex className="connectivity-toggle" tag="div"> + <_Builtin.Link + className="toggle-button user-a" + macro={{ + guid: "df55cee5-5b9b-f84b-ac36-88eb77495a59", + }} + button={false} + data-ix="toggle" + block="inline" + options={{ + href: "#", + }} + {...onlineOfflineToggle} + > + <_Builtin.Block + className="toggle-button-green" + macro={{ + guid: "df55cee5-5b9b-f84b-ac36-88eb77495a59", + }} + tag="div" + data-ix="toggle" + /> + <_Builtin.Block className="button-toggle" tag="div" /> + + <_Builtin.Block + className="user-topbar-connectivity-toggle" + tag="div" + > + {onlineSync ? ( + <_Builtin.Image + className="icon-connectivity-sync" + loading="lazy" + width="auto" + height="auto" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655f54b39092ef46920edbfb_icon-sync.svg" + /> + ) : null} + {online ? ( + <_Builtin.Image + className="icon-connectivity-online" + loading="lazy" + width="auto" + height="auto" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655f54b3969fccde5401be3a_icon-online.svg" + /> + ) : null} + {offline ? ( + <_Builtin.Image + className="icon-connectivity-offline" + loading="lazy" + width="auto" + height="auto" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655f54b3e6277e3be843c635_icon-offline.svg" + /> + ) : null} + + + + <_Builtin.Block className="user-widget-div" tag="div"> + <_Builtin.Block className="widget-canvas" tag="div"> + <_Builtin.HFlex className="widgets-grid" tag="div"> + {content ?? ( + <_Builtin.Block className="widget-pebble-div" tag="div"> + + + )} + + + + <_Builtin.Block className="user-statuslog-div" tag="div"> + <_Builtin.Block + className="user-sdk-component user-a-sdk" + tag="div" + > + <_Builtin.Block className="component-label" tag="div"> + {"SDK"} + + <_Builtin.Block className="user-sqlite-component" tag="div"> + <_Builtin.Block className="component-label" tag="div"> + {"SQLite"} + + + + <_Builtin.VFlex className="user-console" tag="div"> + <_Builtin.Block + className="user-console-label-user-a user-a" + tag="div" + > + {"Console"} + + <_Builtin.Block className="user-statuslog-text" tag="div"> + {logText} + + + + + <_Builtin.Block className="widget-controls-div" tag="div"> + <_Builtin.Block className="widget-control-buttons" tag="div"> + <_Builtin.Link + className="widget-button-user-a button-create widget-create-button-primary" + button={true} + block="" + options={{ + href: "#", + }} + {...buttonCreate} + > + {"Create"} + + <_Builtin.Link + className="widget-button-user-a button-update" + button={true} + block="" + options={{ + href: "#", + }} + {...buttonUpdate} + > + {"Update"} + + <_Builtin.Link + className="widget-button-user-a button-delete" + button={true} + block="" + options={{ + href: "#", + }} + {...buttonDelete} + > + {"Delete"} + + + + + <_Builtin.VFlex className="user-a-io" tag="div"> + <_Builtin.Block className="user-a-writes" tag="div"> + <_Builtin.Block className="user-a-writes-div" tag="div"> + {writesFalse ? ( + <_Builtin.Block className="user-a-writes-io" tag="div"> + <_Builtin.Block className="user-a-writes-io-pill" tag="div"> + <_Builtin.Block + className="user-a-writes-io-label" + tag="div" + > + {"uploads"} + + + + ) : null} + + <_Builtin.Block className="user-a-write-path" tag="div"> + <_Builtin.HtmlEmbed + className="user-a-write-svg" + value="%3Csvg%20id%3D%22user-a-write-arrow%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20168%2024%22%3E%0A%3Cdefs%3E%0A%3Cstyle%3E%0A%3Aroot%20%7B%0A--animation-stroke-dasharray%3A%2012%3B%0A--animation-stroke-dashoffset%3A%20100%3B%0A%7D%0A.user-a-write-line%7Bfill%3Anone%3Bstroke%3Avar(--user-orange)%3Bstroke-miterlimit%3A10%3Bstroke-width%3A2px%3Banimation%3Anone%202s%20linear%20infinite%20reverse%3B%7D%0A.line-animate%20%7B%0A%09stroke-dasharray%3A%20var(--animation-stroke-dasharray)%3B%0A%09stroke-dashoffset%3A%20var(--animation-stroke-dashoffset)%3B%0A%20%20animation-name%3A%20flow%3B%0A%7D%0A%40keyframes%20flow%20%7B%0A%20%20%20%20100%25%20%7B%0A%20%20%20%20%20%20%20%20stroke-dashoffset%3A%200%3B%0A%20%20%20%20%7D%0A%7D%0A.user-a-write-arrowhead%7Bfill%3Avar(--user-orange)%3Bstroke-width%3A0px%3B%7D%0A%3C%2Fstyle%3E%0A%3C%2Fdefs%3E%0A%3Cline%20id%3D%22animate-arrows%22%20class%3D%22user-a-write-line%22%20x1%3D%22165.6%22%20y1%3D%2212%22%20y2%3D%2212%22%2F%3E%0A%3Cpath%20class%3D%22user-a-write-arrowhead%22%20d%3D%22m151.93%2C2.4c-.3.46-.16%2C1.08.31%2C1.38l12.91%2C8.22-12.91%2C8.22c-.47.3-.6.92-.31%2C1.38.3.46.92.6%2C1.38.31l14.23-9.06c.29-.18.46-.5.46-.84s-.17-.66-.46-.84l-14.23-9.06c-.17-.11-.35-.16-.54-.16-.33%2C0-.65.16-.84.46Z%22%2F%3E%0A%3C%2Fsvg%3E" + {...writePath} + /> + + + <_Builtin.Block className="spacer-div" tag="div" /> + <_Builtin.Block className="user-a-reads" tag="div"> + <_Builtin.Block className="user-a-reads-div" tag="div"> + {readsFalse ? ( + <_Builtin.Block className="user-a-reads-io" tag="div"> + <_Builtin.Block className="user-a-reads-io-pill" tag="div"> + <_Builtin.Block + className="user-a-reads-io-label" + tag="div" + > + {"downloads"} + + + + ) : null} + + <_Builtin.Block className="user-a-read-path" tag="div"> + <_Builtin.HtmlEmbed + className="user-a-read-svg" + value="%3Csvg%20id%3D%22user-a-read-arrow%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20168%2024%22%3E%0A%3Cdefs%3E%0A%3Cstyle%3E%0A%3Aroot%20%7B%0A--animation-stroke-dasharray%3A%2012%3B%0A--animation-stroke-dashoffset%3A%20100%3B%0A%7D%0A.user-a-read-line%7Bfill%3Anone%3Bstroke%3Avar(--user-orange)%3Bstroke-miterlimit%3A10%3Bstroke-width%3A2px%3Banimation%3Anone%202s%20linear%20infinite%20reverse%3B%7D%0A.line-animate%20%7B%0A%09stroke-dasharray%3A%20var(--animation-stroke-dasharray)%3B%0A%09stroke-dashoffset%3A%20var(--animation-stroke-dashoffset)%3B%0A%20%20animation-name%3A%20flow%3B%0A%7D%0A%40keyframes%20flow%20%7B%0A%20%20%20%20100%25%20%7B%0A%20%20%20%20%20%20%20%20stroke-dashoffset%3A%200%3B%0A%20%20%20%20%7D%0A%7D%0A.user-a-read-arrowhead%7Bfill%3Avar(--user-orange)%3Bstroke-width%3A0px%3B%7D%0A%3C%2Fstyle%3E%0A%3C%2Fdefs%3E%0A%3Cline%20id%3D%22animate-arrows%22%20class%3D%22user-a-read-line%22%20x1%3D%222.4%22%20y1%3D%2212%22%20x2%3D%22168%22%20y2%3D%2212%22%2F%3E%0A%3Cpath%20class%3D%22user-a-read-arrowhead%22%20d%3D%22m16.07%2C21.6c.3-.46.16-1.08-.31-1.38L2.86%2C12%2C15.77%2C3.78c.47-.3.6-.92.31-1.38-.3-.46-.92-.6-1.38-.31L.46%2C11.16c-.29.18-.46.5-.46.84s.17.66.46.84l14.23%2C9.06c.17.11.35.16.54.16.33%2C0%2C.65-.16.84-.46Z%22%2F%3E%3C%2Fsvg%3E" + {...readPath} + /> + + + + + )} + + ); +} diff --git a/demos/react-multi-client/src/devlink/UserB.d.ts b/demos/react-multi-client/src/devlink/UserB.d.ts new file mode 100644 index 00000000..16a8a8a3 --- /dev/null +++ b/demos/react-multi-client/src/devlink/UserB.d.ts @@ -0,0 +1,22 @@ +import * as React from "react"; +import * as Types from "./types"; + +declare function UserB(props: { + as?: React.ElementType; + userBSlot?: Types.Devlink.Slot; + writesFalse?: Types.Visibility.VisibilityConditions; + writesTrue?: Types.Visibility.VisibilityConditions; + writePath?: Types.Devlink.RuntimeProps; + readsFalse?: Types.Visibility.VisibilityConditions; + readsTrue?: Types.Visibility.VisibilityConditions; + readPath?: Types.Devlink.RuntimeProps; + online?: Types.Visibility.VisibilityConditions; + offline?: Types.Visibility.VisibilityConditions; + content?: Types.Devlink.Slot; + buttonCreate?: Types.Devlink.RuntimeProps; + buttonUpdate?: Types.Devlink.RuntimeProps; + buttonDelete?: Types.Devlink.RuntimeProps; + logText?: React.ReactNode; + onlineSync?: Types.Visibility.VisibilityConditions; + onlineOfflineToggle?: Types.Devlink.RuntimeProps; +}): React.JSX.Element; diff --git a/demos/react-multi-client/src/devlink/UserB.jsx b/demos/react-multi-client/src/devlink/UserB.jsx new file mode 100644 index 00000000..dfa05126 --- /dev/null +++ b/demos/react-multi-client/src/devlink/UserB.jsx @@ -0,0 +1,233 @@ +import React from "react"; +import * as _Builtin from "./_Builtin"; +import { DataTriangle } from "./DataTriangle"; + +export function UserB({ + as: _Component = _Builtin.HFlex, + userBSlot, + writesFalse, + writesTrue = false, + writePath, + readsFalse, + readsTrue = false, + readPath, + online, + offline = false, + content, + buttonCreate = {}, + buttonUpdate = {}, + buttonDelete = {}, + logText = ( + <> + {"1 row created. Total time: 5 ms"} +
+ {"1 row uploaded. Total time: 124 ms"} + + ), + onlineSync = false, + onlineOfflineToggle, +}) { + return ( + <_Component className="user-b-slot" tag="div"> + {userBSlot ?? ( + <_Builtin.HFlex className="widget-h-flex" tag="div"> + <_Builtin.VFlex className="user-b-io" tag="div"> + <_Builtin.Block className="user-b-writes" tag="div"> + <_Builtin.Block className="user-b-writes-div" tag="div"> + {writesFalse ? ( + <_Builtin.Block className="user-b-writes-io" tag="div"> + <_Builtin.Block className="user-b-writes-io-pill" tag="div"> + <_Builtin.Block + className="user-b-writes-io-label" + tag="div" + > + {"uploads"} + + + + ) : null} + + <_Builtin.Block className="user-b-write-path" tag="div"> + <_Builtin.HtmlEmbed + className="user-b-write-svg" + value="%3Csvg%20id%3D%22user-b-write-arrow%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20168%2024%22%3E%0A%3Cdefs%3E%0A%3Cstyle%3E%0A%3Aroot%20%7B%0A--animation-stroke-dasharray%3A%2012%3B%0A--animation-stroke-dashoffset%3A%20100%3B%0A%7D%0A.user-b-write-line%7Bfill%3Anone%3Bstroke%3Avar(--user-pink)%3Bstroke-miterlimit%3A10%3Bstroke-width%3A2px%3Banimation%3Anone%202s%20linear%20infinite%20reverse%3B%7D%0A.line-animate%20%7B%0A%09stroke-dasharray%3A%20var(--animation-stroke-dasharray)%3B%0A%09stroke-dashoffset%3A%20var(--animation-stroke-dashoffset)%3B%0A%20%20animation-name%3A%20flow%3B%0A%7D%0A%40keyframes%20flow%20%7B%0A%20%20%20%20100%25%20%7B%0A%20%20%20%20%20%20%20%20stroke-dashoffset%3A%200%3B%0A%20%20%20%20%7D%0A%7D%0A.user-b-write-arrowhead%7Bfill%3Avar(--user-pink)%3Bstroke-width%3A0px%3B%7D%0A%3C%2Fstyle%3E%0A%3C%2Fdefs%3E%0A%3Cline%20id%3D%22animate-arrows%22%20class%3D%22user-b-write-line%22%20x1%3D%222.4%22%20y1%3D%2212%22%20x2%3D%22168%22%20y2%3D%2212%22%2F%3E%0A%3Cpath%20class%3D%22user-b-write-arrowhead%22%20d%3D%22m16.07%2C21.6c.3-.46.16-1.08-.31-1.38L2.86%2C12%2C15.77%2C3.78c.47-.3.6-.92.31-1.38-.3-.46-.92-.6-1.38-.31L.46%2C11.16c-.29.18-.46.5-.46.84s.17.66.46.84l14.23%2C9.06c.17.11.35.16.54.16.33%2C0%2C.65-.16.84-.46Z%22%2F%3E%3C%2Fsvg%3E" + {...writePath} + /> + + + <_Builtin.Block className="spacer-div" tag="div" /> + <_Builtin.Block className="user-b-reads" tag="div"> + <_Builtin.Block className="user-b-reads-div" tag="div"> + {readsFalse ? ( + <_Builtin.Block className="user-b-reads-io" tag="div"> + <_Builtin.Block className="user-b-reads-io-pill" tag="div"> + <_Builtin.Block + className="user-b-reads-io-label" + tag="div" + > + {"downloads"} + + + + ) : null} + + <_Builtin.Block className="user-b-read-path" tag="div"> + <_Builtin.HtmlEmbed + className="user-b-read-svg" + value="%3Csvg%20id%3D%22user-b-read-arrow%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20168%2024%22%3E%0A%3Cdefs%3E%0A%3Cstyle%3E%0A%3Aroot%20%7B%0A--animation-stroke-dasharray%3A%2012%3B%0A--animation-stroke-dashoffset%3A%20100%3B%0A%7D%0A.user-b-read-line%7Bfill%3Anone%3Bstroke%3Avar(--user-pink)%3Bstroke-miterlimit%3A10%3Bstroke-width%3A2px%3Banimation%3Anone%202s%20linear%20infinite%20reverse%3B%7D%0A.line-animate%20%7B%0A%09stroke-dasharray%3A%20var(--animation-stroke-dasharray)%3B%0A%09stroke-dashoffset%3A%20var(--animation-stroke-dashoffset)%3B%0A%20%20animation-name%3A%20flow%3B%0A%7D%0A%40keyframes%20flow%20%7B%0A%20%20%20%20100%25%20%7B%0A%20%20%20%20%20%20%20%20stroke-dashoffset%3A%200%3B%0A%20%20%20%20%7D%0A%7D%0A.user-b-read-arrowhead%7Bfill%3Avar(--user-pink)%3Bstroke-width%3A0px%3B%7D%0A%3C%2Fstyle%3E%0A%3C%2Fdefs%3E%0A%3Cline%20id%3D%22animate-arrows%22%20class%3D%22user-b-read-line%22%20x1%3D%22165.6%22%20y1%3D%2212%22%20y2%3D%2212%22%2F%3E%0A%3Cpath%20class%3D%22user-b-read-arrowhead%22%20d%3D%22m151.93%2C2.4c-.3.46-.16%2C1.08.31%2C1.38l12.91%2C8.22-12.91%2C8.22c-.47.3-.6.92-.31%2C1.38.3.46.92.6%2C1.38.31l14.23-9.06c.29-.18.46-.5.46-.84s-.17-.66-.46-.84l-14.23-9.06c-.17-.11-.35-.16-.54-.16-.33%2C0-.65.16-.84.46Z%22%2F%3E%0A%3C%2Fsvg%3E" + {...readPath} + /> + + + + <_Builtin.VFlex className="widget-v-flex" tag="div"> + <_Builtin.Block className="widget-user-div user-b" tag="div"> + <_Builtin.Block className="user-topbar-div" tag="div"> + <_Builtin.Block className="user-topbar-label user-b" tag="div"> + {"User B"} + + <_Builtin.HFlex + className="connectivity-toggle-user-b" + tag="div" + > + <_Builtin.Link + className="toggle-button user-b" + macro={{ + guid: "df55cee5-5b9b-f84b-ac36-88eb77495a59", + }} + button={false} + data-ix="toggle" + block="inline" + options={{ + href: "#", + }} + {...onlineOfflineToggle} + > + <_Builtin.Block + className="toggle-button-green" + macro={{ + guid: "df55cee5-5b9b-f84b-ac36-88eb77495a59", + }} + tag="div" + data-ix="toggle" + /> + <_Builtin.Block className="button-toggle" tag="div" /> + + <_Builtin.Block + className="user-topbar-connectivity-toggle" + tag="div" + > + {onlineSync ? ( + <_Builtin.Image + className="image-connectivity-sync" + loading="lazy" + width="auto" + height="auto" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655f54b39092ef46920edbfb_icon-sync.svg" + /> + ) : null} + {online ? ( + <_Builtin.Image + className="icon-connectivity-online" + loading="lazy" + width="auto" + height="auto" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655f54b3969fccde5401be3a_icon-online.svg" + /> + ) : null} + {offline ? ( + <_Builtin.Image + className="icon-connectivity-offline" + loading="lazy" + width="auto" + height="auto" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655f54b3e6277e3be843c635_icon-offline.svg" + /> + ) : null} + + + + <_Builtin.Block className="user-widget-div" tag="div"> + <_Builtin.Block className="widget-canvas" tag="div"> + <_Builtin.HFlex className="widgets-grid" tag="div"> + {content ?? ( + <_Builtin.Block className="widget-pebble-div" tag="div"> + + + )} + + + + <_Builtin.Block className="user-statuslog-div" tag="div"> + <_Builtin.Block + className="user-sdk-component user-b-sdk" + tag="div" + > + <_Builtin.Block className="component-label" tag="div"> + {"SDK"} + + <_Builtin.Block className="user-sqlite-component" tag="div"> + <_Builtin.Block className="component-label" tag="div"> + {"SQLite"} + + + + <_Builtin.VFlex className="user-console" tag="div"> + <_Builtin.Block + className="user-console-label-user-b user-b" + tag="div" + > + {"Console"} + + <_Builtin.Block className="user-statuslog-text" tag="div"> + {logText} + + + + + <_Builtin.Block className="widget-controls-div" tag="div"> + <_Builtin.Block className="widget-control-buttons" tag="div"> + <_Builtin.Link + className="widget-button-user-b button-create widget-create-button-primary" + button={true} + block="" + options={{ + href: "#", + }} + {...buttonCreate} + > + {"Create"} + + <_Builtin.Link + className="widget-button-user-b button-update" + button={true} + block="" + options={{ + href: "#", + }} + {...buttonUpdate} + > + {"Update"} + + <_Builtin.Link + className="widget-button-user-b button-delete" + button={true} + block="" + options={{ + href: "#", + }} + {...buttonDelete} + > + {"Delete"} + + + + + + )} + + ); +} diff --git a/demos/react-multi-client/src/devlink/WebDemoWidget.d.ts b/demos/react-multi-client/src/devlink/WebDemoWidget.d.ts new file mode 100644 index 00000000..e97c3287 --- /dev/null +++ b/demos/react-multi-client/src/devlink/WebDemoWidget.d.ts @@ -0,0 +1,36 @@ +import * as React from "react"; +import * as Types from "./types"; + +declare function WebDemoWidget(props: { + as?: React.ElementType; + userAContent?: Types.Devlink.Slot; + userBContent?: Types.Devlink.Slot; + userAButtonCreate?: Types.Devlink.RuntimeProps; + userAButtonUpdate?: Types.Devlink.RuntimeProps; + userAButtonDelete?: Types.Devlink.RuntimeProps; + userBButtonCreate?: Types.Devlink.RuntimeProps; + userBButtonUpdate?: Types.Devlink.RuntimeProps; + userBButtonDelete?: Types.Devlink.RuntimeProps; + userALogText?: React.ReactNode; + userBLogText?: React.ReactNode; + userAOnline?: Types.Visibility.VisibilityConditions; + userBOnline?: Types.Visibility.VisibilityConditions; + userAOffline?: Types.Visibility.VisibilityConditions; + userBOffline?: Types.Visibility.VisibilityConditions; + userAWritesFalse?: Types.Visibility.VisibilityConditions; + userAWritesTrue?: Types.Visibility.VisibilityConditions; + userBWritesFalse?: Types.Visibility.VisibilityConditions; + userBWritesTrue?: Types.Visibility.VisibilityConditions; + userAReadsFalse?: Types.Visibility.VisibilityConditions; + userAReadsTrue?: Types.Visibility.VisibilityConditions; + userBReadsFalse?: Types.Visibility.VisibilityConditions; + userBReadsTrue?: Types.Visibility.VisibilityConditions; + userBRead?: Types.Devlink.RuntimeProps; + userBWrite?: Types.Devlink.RuntimeProps; + userARead?: Types.Devlink.RuntimeProps; + userAWrite?: Types.Devlink.RuntimeProps; + writeBackendToDb?: Types.Devlink.RuntimeProps; + readDbToPs?: Types.Devlink.RuntimeProps; + userASlot?: Types.Devlink.Slot; + userBSlot?: Types.Devlink.Slot; +}): React.JSX.Element; diff --git a/demos/react-multi-client/src/devlink/WebDemoWidget.jsx b/demos/react-multi-client/src/devlink/WebDemoWidget.jsx new file mode 100644 index 00000000..207beebd --- /dev/null +++ b/demos/react-multi-client/src/devlink/WebDemoWidget.jsx @@ -0,0 +1,153 @@ +import React from "react"; +import * as _Builtin from "./_Builtin"; +import { UserA } from "./UserA"; +import { UserB } from "./UserB"; + +export function WebDemoWidget({ + as: _Component = _Builtin.Block, + userAContent, + userBContent, + userAButtonCreate = {}, + userAButtonUpdate = {}, + userAButtonDelete = {}, + userBButtonCreate = {}, + userBButtonUpdate = {}, + userBButtonDelete = {}, + userALogText = ( + <> + {"1 row created. Total time: 5 ms"} +
+ {"1 row uploaded. Total time: 124 ms"} + + ), + userBLogText = ( + <> + {"1 row created. Total time: 5 ms"} +
+ {"1 row uploaded. Total time: 124 ms"} + + ), + userAOnline, + userBOnline, + userAOffline = false, + userBOffline = false, + userAWritesFalse, + userAWritesTrue = false, + userBWritesFalse, + userBWritesTrue = false, + userAReadsFalse, + userAReadsTrue = false, + userBReadsFalse, + userBReadsTrue = false, + userBRead, + userBWrite, + userARead, + userAWrite, + writeBackendToDb, + readDbToPs, + userASlot, + userBSlot, +}) { + return ( + <_Component className="web-demo-widget" tag="div"> + <_Builtin.Block className="demo-widget-div" tag="div"> + + <_Builtin.Block className="widget-under-hood-components-div" tag="div"> + <_Builtin.Block + className="component-under-hood component-backend" + tag="div" + > + <_Builtin.Block className="component-label" tag="div"> + {"BACKEND"} + + + <_Builtin.Block + className="component-arrow-backend-to-db" + tag="div" + {...writeBackendToDb} + > + <_Builtin.Image + className="arrow-backend-db" + loading="lazy" + width="auto" + height="24" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655f8d9dcee745a0be785a3c_path-arrow-component-001.svg" + /> + + <_Builtin.Block + className="component-under-hood component-postgres" + tag="div" + > + <_Builtin.Block className="component-label" tag="div"> + {"POSTGRES"} + + + <_Builtin.Block + className="component-arrow-db-to-ps" + tag="div" + {...readDbToPs} + > + <_Builtin.Image + className="arrow-db-ps" + loading="lazy" + width="Auto" + height="24" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655f8d9dcee745a0be785a3c_path-arrow-component-001.svg" + /> + + <_Builtin.Block + className="component-under-hood component-powersync" + tag="div" + > + <_Builtin.Image + className="component-icon-ps" + loading="lazy" + width="auto" + height="auto" + alt="" + src="https://uploads-ssl.webflow.com/655cae9c85d542976fbd4b10/655f556ea234aa0d222e5917_icon-powersync.svg" + /> + <_Builtin.Block className="component-label" tag="div"> + {"POWERSYNC"} +
+ {"SERVICE"} + + + + + + + ); +} diff --git a/demos/react-multi-client/src/devlink/_Builtin/BackgroundVideo.d.ts b/demos/react-multi-client/src/devlink/_Builtin/BackgroundVideo.d.ts new file mode 100644 index 00000000..1077fa99 --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/BackgroundVideo.d.ts @@ -0,0 +1,4 @@ +export declare const BackgroundVideoWrapper: any; +export declare const BackgroundVideoPlayPauseButton: any; +export declare const BackgroundVideoPlayPauseButtonPlaying: any; +export declare const BackgroundVideoPlayPauseButtonPaused: any; diff --git a/demos/react-multi-client/src/devlink/_Builtin/BackgroundVideo.jsx b/demos/react-multi-client/src/devlink/_Builtin/BackgroundVideo.jsx new file mode 100644 index 00000000..815587ab --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/BackgroundVideo.jsx @@ -0,0 +1,105 @@ +import React from "react"; +import { cj, debounce } from "../utils"; +const BgVideoContext = React.createContext({ + isPlaying: true, + togglePlay: () => undefined, +}); +export const BackgroundVideoWrapper = React.forwardRef( + function BackgroundVideoWrapper( + { + tag = "div", + className = "", + autoPlay = true, + loop = true, + sources = [], + posterImage = "", + children, + }, + ref + ) { + const [isPlaying, setIsPlaying] = React.useState(autoPlay); + const video = React.useRef(null); + React.useImperativeHandle(ref, () => video.current); + const togglePlay = debounce(() => { + setIsPlaying(!isPlaying); + if (!video?.current) return; + if (video.current.paused) { + video.current.play(); + } else { + video.current.pause(); + } + }); + return ( + + {React.createElement( + tag, + { + className: cj( + className, + "w-background-video", + "w-background-video-atom" + ), + }, + + )} + {children} + + ); + } +); +export const BackgroundVideoPlayPauseButton = React.forwardRef( + function BackgroundVideoPlayPauseButton({ children, className }, ref) { + const { togglePlay } = React.useContext(BgVideoContext); + return ( +
+ +
+ ); + } +); +export const BackgroundVideoPlayPauseButtonPlaying = React.forwardRef( + function BackgroundVideoPlayPauseButtonPlaying({ children }, ref) { + const { isPlaying } = React.useContext(BgVideoContext); + return ( + + ); + } +); +export const BackgroundVideoPlayPauseButtonPaused = React.forwardRef( + function BackgroundVideoPlayPauseButtonPaused({ children }, ref) { + const { isPlaying } = React.useContext(BgVideoContext); + return ( + + ); + } +); diff --git a/demos/react-multi-client/src/devlink/_Builtin/Basic.d.ts b/demos/react-multi-client/src/devlink/_Builtin/Basic.d.ts new file mode 100644 index 00000000..254375e9 --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Basic.d.ts @@ -0,0 +1,180 @@ +import * as React from "react"; +export type ElementProps = + React.HTMLAttributes; +export type Props< + T extends keyof HTMLElementTagNameMap, + U = unknown +> = ElementProps & React.PropsWithChildren; +export declare const Block: React.ForwardRefExoticComponent< + { + tag?: React.ElementType | undefined; + } & { + children?: React.ReactNode; + } & React.HTMLAttributes & + React.RefAttributes +>; +export declare const Span: React.ForwardRefExoticComponent< + React.RefAttributes +>; +export declare const Blockquote: React.ForwardRefExoticComponent< + React.RefAttributes +>; +export type LinkProps = Props< + "a", + { + options?: { + href: string; + target?: "_self" | "_blank"; + preload?: "none" | "prefetch" | "prerender"; + }; + className?: string; + button?: boolean; + block?: string; + } +>; +export declare const Link: React.ForwardRefExoticComponent< + ElementProps<"a"> & { + options?: + | { + href: string; + target?: "_self" | "_blank" | undefined; + preload?: "none" | "prerender" | "prefetch" | undefined; + } + | undefined; + className?: string | undefined; + button?: boolean | undefined; + block?: string | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const List: React.ForwardRefExoticComponent< + ElementProps<"ul"> & { + tag?: React.ElementType | undefined; + unstyled?: boolean | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const ListItem: React.ForwardRefExoticComponent< + ElementProps<"li"> & { + children?: React.ReactNode; + } & React.RefAttributes +>; +type ImageProps = React.DetailedHTMLProps< + React.ImgHTMLAttributes, + HTMLImageElement +>; +export declare const Image: React.ForwardRefExoticComponent< + Omit & React.RefAttributes +>; +export declare const Section: React.ForwardRefExoticComponent< + ElementProps<"section"> & { + tag: React.ElementType; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export type TagProps = Props< + keyof HTMLElementTagNameMap, + { + tag?: React.ElementType; + } +>; +export declare const Container: React.ForwardRefExoticComponent< + ElementProps & { + tag?: React.ElementType | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const BlockContainer: React.ForwardRefExoticComponent< + ElementProps & { + tag?: React.ElementType | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const HFlex: React.ForwardRefExoticComponent< + ElementProps & { + tag?: React.ElementType | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const VFlex: React.ForwardRefExoticComponent< + ElementProps & { + tag?: React.ElementType | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const Layout: React.ForwardRefExoticComponent< + ElementProps & { + tag?: React.ElementType | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const Cell: React.ForwardRefExoticComponent< + ElementProps & { + tag?: React.ElementType | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const HtmlEmbed: React.ForwardRefExoticComponent< + ElementProps<"div"> & { + tag?: React.ElementType | undefined; + value: string; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const Grid: React.ForwardRefExoticComponent< + ElementProps & { + tag?: React.ElementType | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const Icon: React.ForwardRefExoticComponent< + ElementProps<"div"> & { + widget: { + icon: string; + }; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +type ColumnProps = Props< + "div", + { + tag: React.ElementType; + columnClasses?: string; + } +>; +export declare const Column: React.ForwardRefExoticComponent< + ElementProps<"div"> & { + tag: React.ElementType; + columnClasses?: string | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const Row: React.ForwardRefExoticComponent< + ElementProps<"div"> & { + children: React.ReactElement[]; + tag: React.ElementType; + columns: { + [key: string]: string; + }; + value: string; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const NotSupported: React.ForwardRefExoticComponent< + React.RefAttributes +>; +export {}; diff --git a/demos/react-multi-client/src/devlink/_Builtin/Basic.jsx b/demos/react-multi-client/src/devlink/_Builtin/Basic.jsx new file mode 100644 index 00000000..d0b7a6fb --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Basic.jsx @@ -0,0 +1,233 @@ +import * as React from "react"; +import { DevLinkContext } from "../devlinkContext"; +import * as utils from "../utils"; +export const Block = React.forwardRef(function Block( + { tag = "div", ...props }, + ref +) { + return React.createElement(tag, { + ...props, + ref, + }); +}); +export const Span = React.forwardRef(function Span(props, ref) { + return ; +}); +export const Blockquote = React.forwardRef(function Blockquote(props, ref) { + return
; +}); +export const Link = React.forwardRef(function Link( + { + options = { href: "#" }, + className = "", + button = false, + children, + block = "", + ...props + }, + ref +) { + const { renderLink: UserLink } = React.useContext(DevLinkContext); + if (button) className += " w-button"; + if (block === "inline") className += " w-inline-block"; + if (UserLink) { + return ( + + {children} + + ); + } + const { href, target, preload = "none" } = options; + const shouldRenderResource = + preload !== "none" && typeof href === "string" && !href.startsWith("#"); + return ( + <> + + {children} + + {shouldRenderResource && } + + ); +}); +export const List = React.forwardRef(function List( + { tag = "ul", unstyled = true, className = "", ...props }, + ref +) { + return React.createElement(tag, { + role: "list", + className: className + (unstyled ? " w-list-unstyled" : ""), + ...props, + ref, + }); +}); +export const ListItem = React.forwardRef(function ListItem(props, ref) { + return React.createElement("li", { + ...props, + ref, + }); +}); +export const Image = React.forwardRef(function Image({ alt, ...props }, ref) { + const { renderImage: UserImage } = React.useContext(DevLinkContext); + return UserImage ? ( + + ) : ( + {alt + ); +}); +export const Section = React.forwardRef(function Section( + { tag = "section", ...props }, + ref +) { + return React.createElement(tag, { + ...props, + ref, + }); +}); +export const Container = React.forwardRef(function Container( + { tag = "div", className = "", ...props }, + ref +) { + return React.createElement(tag, { + className: className + " w-container", + ref, + ...props, + }); +}); +export const BlockContainer = React.forwardRef(function BlockContainer( + { tag = "div", className = "", ...props }, + ref +) { + return React.createElement(tag, { + className: className + " w-layout-blockcontainer", + ...props, + ref, + }); +}); +export const HFlex = React.forwardRef(function HFlex( + { tag = "div", className = "", ...props }, + ref +) { + return React.createElement(tag, { + className: className + " w-layout-hflex", + ...props, + ref, + }); +}); +export const VFlex = React.forwardRef(function VFlex( + { tag = "div", className = "", ...props }, + ref +) { + return React.createElement(tag, { + className: className + " w-layout-vflex", + ...props, + ref, + }); +}); +export const Layout = React.forwardRef(function Layout( + { tag = "div", className = "", ...props }, + ref +) { + return React.createElement(tag, { + className: className + " w-layout-layout wf-layout-layout", + ...props, + ref, + }); +}); +export const Cell = React.forwardRef(function Cell( + { tag = "div", className = "", ...props }, + ref +) { + return React.createElement(tag, { + className: className + " w-layout-cell", + ...props, + ref, + }); +}); +export const HtmlEmbed = React.forwardRef(function HtmlEmbed( + { tag = "div", className = "", value = "", ...props }, + ref +) { + return React.createElement(tag, { + className: className + " w-embed", + dangerouslySetInnerHTML: { __html: utils.removeUnescaped(value) }, + ...props, + ref, + }); +}); +export const Grid = React.forwardRef(function Grid( + { tag = "div", className = "", ...props }, + ref +) { + return React.createElement(tag, { + className: className + " w-layout-grid", + ...props, + ref, + }); +}); +export const Icon = React.forwardRef(function Icon( + { widget, className = "", ...props }, + ref +) { + return React.createElement("div", { + className: className + ` w-icon-${widget.icon}`, + ...props, + ref, + }); +}); +export const Column = React.forwardRef(function Column( + { tag = "div", className = "", columnClasses = "", ...props }, + ref +) { + return React.createElement(tag, { + className: className + " w-col " + columnClasses, + ...props, + ref, + }); +}); +const transformWidths = (width, index) => { + const widths = width?.split("|") ?? []; + return widths.length > 1 ? widths[index] : width; +}; +const columnClass = (width, key) => { + if (/stack$/.test(width)) return "w-col-stack"; + if (/main$/.test(key)) return `w-col-${width}`; + return `w-col-${key}-${width}`; +}; +export const Row = React.forwardRef(function Row( + { tag = "div", className = "", columns, children, ...props }, + ref +) { + return React.createElement( + tag, + { className: className + " w-row", ...props, ref }, + columns + ? React.Children.map(children, (child, index) => { + if (child && typeof child === "object" && child.type !== Column) + return child; + const columnClasses = Object.entries(columns ?? {}).reduce( + (acc, [key, value]) => { + const width = transformWidths( + value === "custom" ? "6|6" : value, + index + ); + acc.add(width ? columnClass(width, key) : ""); + return acc; + }, + new Set() + ); + return React.cloneElement(child, { + columnClasses: [...columnClasses].join(" "), + ...child.props, + }); + }) + : children + ); +}); +export const NotSupported = React.forwardRef(function NotSupported( + { _atom = "" }, + ref +) { + return ( +
{`This builtin is not currently supported: ${_atom}`}
+ ); +}); diff --git a/demos/react-multi-client/src/devlink/_Builtin/Dropdown.d.ts b/demos/react-multi-client/src/devlink/_Builtin/Dropdown.d.ts new file mode 100644 index 00000000..23fe2a0b --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Dropdown.d.ts @@ -0,0 +1,65 @@ +import * as React from "react"; +import { LinkProps } from "./Basic"; +type DropdownProps = React.PropsWithChildren<{ + tag?: keyof HTMLElementTagNameMap; + className?: string; +}>; +export declare const DropdownWrapper: React.ForwardRefExoticComponent< + { + tag?: keyof HTMLElementTagNameMap | undefined; + className?: string | undefined; + } & { + children?: React.ReactNode; + } & { + children: React.ReactElement; + delay: number; + hover: boolean; + } & React.RefAttributes +>; +type DropdownToggleProps = DropdownProps; +export declare const DropdownToggle: React.ForwardRefExoticComponent< + { + tag?: keyof HTMLElementTagNameMap | undefined; + className?: string | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +type DropdownListProps = DropdownProps & { + children: + | React.ReactElement + | React.ReactElement[]; +}; +export declare const DropdownList: React.ForwardRefExoticComponent< + { + tag?: keyof HTMLElementTagNameMap | undefined; + className?: string | undefined; + } & { + children?: React.ReactNode; + } & { + children: + | React.ReactElement + | React.ReactElement[]; + } & React.RefAttributes +>; +type DropdownLinkProps = DropdownProps & LinkProps; +export declare const DropdownLink: React.ForwardRefExoticComponent< + { + tag?: keyof HTMLElementTagNameMap | undefined; + className?: string | undefined; + } & { + children?: React.ReactNode; + } & import("./Basic").ElementProps<"a"> & { + options?: + | { + href: string; + target?: "_self" | "_blank" | undefined; + preload?: "none" | "prerender" | "prefetch" | undefined; + } + | undefined; + className?: string | undefined; + button?: boolean | undefined; + block?: string | undefined; + } & React.RefAttributes +>; +export {}; diff --git a/demos/react-multi-client/src/devlink/_Builtin/Dropdown.jsx b/demos/react-multi-client/src/devlink/_Builtin/Dropdown.jsx new file mode 100644 index 00000000..38b18cbe --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Dropdown.jsx @@ -0,0 +1,211 @@ +import * as React from "react"; +import { useIXEvent } from "../interactions"; +import { cj, useClickOut, KEY_CODES } from "../utils"; +import { Link } from "./Basic"; +import { NavbarContext } from "./Navbar"; +function getLinksList(root) { + return root.querySelectorAll(".w-dropdown-list .w-dropdown-link"); +} +const DropdownContext = React.createContext({ + root: undefined, + isOpen: false, + toggleOpen: () => undefined, + setFocusedLink: () => undefined, + hover: false, +}); +const INITIAL_DROPDOWN_STATE = { + isOpen: false, + openingCount: 0, +}; +export const DropdownWrapper = React.forwardRef(function DropdownWrapper( + { delay, hover, ...props }, + ref +) { + const root = React.useRef(null); + const [{ isOpen }, setIsOpen] = React.useState(INITIAL_DROPDOWN_STATE); + const [focusedLink, setFocusedLink] = React.useState(-1); + const closeTimeoutRef = React.useRef(); + React.useImperativeHandle(ref, () => root.current); + React.useEffect(() => { + return () => { + clearTimeout(closeTimeoutRef.current); + }; + }, []); + const toggleOpen = React.useCallback(() => { + clearTimeout(closeTimeoutRef.current); + setFocusedLink(-1); + setIsOpen(({ openingCount, ...rest }) => ({ + ...rest, + openingCount: openingCount + 1, + })); + if (delay > 0 && isOpen) { + closeTimeoutRef.current = setTimeout(() => { + setIsOpen(({ openingCount }) => ({ + openingCount, + isOpen: openingCount % 2 === 1, + })); + }, delay); + } else { + setIsOpen(({ openingCount }) => ({ + openingCount, + isOpen: openingCount % 2 === 1, + })); + } + }, [hover, isOpen, delay]); + const closeDropdown = React.useCallback( + () => setIsOpen(INITIAL_DROPDOWN_STATE), + [setIsOpen] + ); + useClickOut(root, closeDropdown); + useIXEvent(root.current, isOpen); + React.useEffect(() => { + if (root.current) { + const links = getLinksList(root.current); + links[focusedLink ?? 0]?.focus(); + } + }, [focusedLink]); + return ( + + + + ); +}); +function Dropdown({ tag = "div", className = "", ...props }) { + const { root, setFocusedLink, hover, toggleOpen } = + React.useContext(DropdownContext); + const { isOpen: isNavbarOpen } = React.useContext(NavbarContext); + const handleFocus = (e) => { + const linkList = root.current ? Array.from(getLinksList(root.current)) : []; + const linkAmount = linkList.length; + switch (e.key) { + case KEY_CODES.ARROW_LEFT: + case KEY_CODES.ARROW_UP: { + e.preventDefault(); + setFocusedLink((prev) => Math.max(prev - 1, 0)); + break; + } + case KEY_CODES.ARROW_RIGHT: + case KEY_CODES.ARROW_DOWN: { + e.preventDefault(); + setFocusedLink((prev) => Math.min(prev + 1, linkAmount - 1)); + break; + } + case KEY_CODES.HOME: { + e.preventDefault(); + setFocusedLink(0); + break; + } + case KEY_CODES.END: { + e.preventDefault(); + setFocusedLink(linkAmount - 1); + break; + } + case KEY_CODES.TAB: { + setTimeout(() => { + setFocusedLink( + linkList.findIndex((link) => link === document.activeElement) + ); + }, 0); + break; + } + case KEY_CODES.SPACE: { + e.preventDefault(); + break; + } + default: { + return; + } + } + }; + return React.createElement(tag, { + ...props, + ref: root, + onKeyDown: handleFocus, + onMouseEnter: () => { + if (hover) { + toggleOpen(); + } + }, + onMouseLeave: () => { + if (hover) { + toggleOpen(); + } + }, + className: cj( + className, + "w-dropdown", + isNavbarOpen && "w--nav-dropdown-open" + ), + }); +} +export const DropdownToggle = React.forwardRef(function DropdownToggle( + { tag = "div", className = "", ...props }, + ref +) { + const { isOpen, toggleOpen, hover } = React.useContext(DropdownContext); + const { isOpen: isNavbarOpen } = React.useContext(NavbarContext); + return React.createElement(tag, { + ...props, + "aria-haspopup": "menu", + "aria-expanded": isOpen, + className: cj( + className, + "w-dropdown-toggle", + isNavbarOpen && "w--nav-dropdown-toggle-open" + ), + onClick: () => { + if (!hover) toggleOpen(); + }, + onKeyDown: (e) => { + if (e.key === KEY_CODES.ENTER || e.key === KEY_CODES.SPACE) { + toggleOpen(); + e.stopPropagation(); + return e.preventDefault(); + } + }, + role: "button", + tabIndex: 0, + ref, + }); +}); +export const DropdownList = React.forwardRef(function DropdownList( + { tag = "nav", className = "", ...props }, + ref +) { + const { isOpen } = React.useContext(DropdownContext); + const { isOpen: isNavbarOpen } = React.useContext(NavbarContext); + return React.createElement(tag, { + ...props, + className: cj( + className, + "w-dropdown-list", + isOpen && "w--open", + isNavbarOpen && "w--nav-dropdown-list-open" + ), + ref, + }); +}); +export const DropdownLink = React.forwardRef(function DropdownLink( + { className = "", ...props }, + ref +) { + const { isOpen: isNavbarOpen } = React.useContext(NavbarContext); + return React.createElement(Link, { + ...props, + className: cj( + className, + "w-dropdown-link", + isNavbarOpen && "w--nav-link-open" + ), + tabIndex: 0, + ref, + }); +}); diff --git a/demos/react-multi-client/src/devlink/_Builtin/Facebook.d.ts b/demos/react-multi-client/src/devlink/_Builtin/Facebook.d.ts new file mode 100644 index 00000000..3b43d939 --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Facebook.d.ts @@ -0,0 +1,13 @@ +import * as React from "react"; +export declare const Facebook: React.ForwardRefExoticComponent< + { + className?: string | undefined; + layout?: string | undefined; + width?: number | undefined; + height?: number | undefined; + url?: string | undefined; + locale?: string | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; diff --git a/demos/react-multi-client/src/devlink/_Builtin/Facebook.jsx b/demos/react-multi-client/src/devlink/_Builtin/Facebook.jsx new file mode 100644 index 00000000..b01a6c26 --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Facebook.jsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import { isUrl } from "../utils"; +export const Facebook = React.forwardRef(function Facebook( + { + className = "", + layout = "standard", + width = 250, + height = 50, + url = "https://facebook.com/webflow", + locale = "en_US", + ...props + }, + ref +) { + if (!isUrl(url)) { + url = "https://facebook.com/webflow"; + } + if (!/^http/.test(url)) { + url = "http://" + url; + } + const params = { + href: url, + layout, + locale, + action: "like", + show_faces: "false", + share: "false", + }; + const queryParams = Object.keys(params).map( + (key) => `${key}=${encodeURIComponent(params[key])}` + ); + const frameSrc = `https://www.facebook.com/plugins/like.php?${queryParams.join( + "&" + )}`; + return ( +
+ +
+ ); +}); diff --git a/demos/react-multi-client/src/devlink/_Builtin/Form.d.ts b/demos/react-multi-client/src/devlink/_Builtin/Form.d.ts new file mode 100644 index 00000000..ceddffc3 --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Form.d.ts @@ -0,0 +1,40 @@ +declare global { + interface Window { + grecaptcha: any; + } +} +export declare const FormWrapper: any; +export declare const FormForm: any; +export declare const FormBlockLabel: any; +export declare const FormTextInput: any; +export declare const FormTextarea: any; +export declare const FormInlineLabel: any; +export declare const FormCheckboxWrapper: any; +export declare const FormRadioWrapper: any; +export declare const FormBooleanInput: any; +export declare const FormCheckboxInput: any; +export declare const FormRadioInput: any; +export declare const FormFileUploadWrapper: any; +export declare const _FormFileUploadWrapper: any; +export declare const FormFileUploadDefault: any; +export declare const FormFileUploadInput: any; +export declare const FormFileUploadLabel: any; +export declare const FormFileUploadText: any; +export declare const FormFileUploadInfo: any; +export declare const FormFileUploadUploading: any; +export declare const FormFileUploadUploadingBtn: any; +export declare const FormFileUploadUploadingIcon: any; +export declare const FormFileUploadSuccess: any; +export declare const FormFileUploadFile: any; +export declare const FormFileUploadFileName: any; +export declare const FormFileUploadRemoveLink: any; +export declare const FormFileUploadError: any; +export declare const FormFileUploadErrorMsg: any; +export declare const FormButton: any; +export declare const SearchForm: any; +export declare const SearchInput: any; +export declare const SearchButton: any; +export declare const FormSuccessMessage: any; +export declare const FormErrorMessage: any; +export declare const FormSelect: any; +export declare const FormReCaptcha: any; diff --git a/demos/react-multi-client/src/devlink/_Builtin/Form.jsx b/demos/react-multi-client/src/devlink/_Builtin/Form.jsx new file mode 100644 index 00000000..53ef1ab7 --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Form.jsx @@ -0,0 +1,575 @@ +import React from "react"; +import { loadScript } from "../utils"; +function onKeyDownInputHandlers(e) { + e.stopPropagation(); +} +export const FormWrapper = React.forwardRef(function FormWrapper( + { + className = "", + state: initialState = "normal", + onSubmit, + children, + ...props + }, + ref +) { + const [state, setState] = React.useState(initialState); + const formName = + (children.find((c) => c.type === FormForm)?.props)["data-name"] ?? "Form"; + return React.createElement( + "div", + { + className: className + " w-form", + ...props, + ref, + }, + React.Children.map(children, (child) => { + if (child.type === FormForm) { + const style = {}; + if (state === "success") { + style.display = "none"; + } + return React.cloneElement(child, { + ...child.props, + style, + onSubmit: (e) => { + try { + e.preventDefault(); + if (window.grecaptcha) { + if (!window.grecaptcha?.getResponse()) { + alert(`Please confirm you’re not a robot.`); + return; + } + } + if (onSubmit) { + onSubmit(e); + } + setState("success"); + } catch (err) { + setState("error"); + throw err; + } + }, + "aria-label": formName, + }); + } + if (child.type === FormSuccessMessage) { + const style = {}; + if (state === "success") { + style.display = "block"; + } + if (state === "error") { + style.display = "none"; + } + return React.cloneElement(child, { + ...child.props, + style, + tabIndex: -1, + role: "region", + "aria-label": `${formName} success`, + }); + } + if (child.type === FormErrorMessage) { + const style = {}; + if (state === "success") { + style.display = "none"; + } + if (state === "error") { + style.display = "block"; + } + return React.cloneElement(child, { + ...child.props, + tabIndex: -1, + role: "region", + "aria-label": `${formName} failure`, + style, + }); + } + return child; + }) + ); +}); +export const FormForm = React.forwardRef(function FormForm(props, ref) { + return React.createElement("form", { ...props, ref }); +}); +export const FormBlockLabel = React.forwardRef(function FormBlockLabel( + props, + ref +) { + return React.createElement("label", { ...props, ref }); +}); +export const FormTextInput = React.forwardRef(function FormTextInput( + { className = "", ...props }, + ref +) { + return React.createElement("input", { + ...props, + className: className + " w-input", + onKeyDown: onKeyDownInputHandlers, + ref, + }); +}); +export const FormTextarea = React.forwardRef(function FormTextarea( + { className = "", ...props }, + ref +) { + return React.createElement("textarea", { + ...props, + className: className + " w-input", + onKeyDown: onKeyDownInputHandlers, + ref, + }); +}); +export const FormInlineLabel = React.forwardRef(function FormInlineLabel( + { className = "", ...props }, + ref +) { + return React.createElement("span", { + className: className + " w-form-label", + ...props, + ref, + }); +}); +export const FormCheckboxWrapper = React.forwardRef( + function FormCheckboxWrapper({ className = "", ...props }, ref) { + return React.createElement("label", { + className: className + " w-checkbox", + ...props, + ref, + }); + } +); +export const FormRadioWrapper = React.forwardRef(function FormRadioWrapper( + { className = "", ...props }, + ref +) { + return React.createElement("label", { + className: className + " w-radio", + ...props, + ref, + }); +}); +const HIDE_DEFAULT_INPUT_STYLES = { + opacity: 0, + position: "absolute", + zIndex: -1, +}; +const CHECKED_CLASS = "w--redirected-checked"; +const FOCUSED_CLASS = "w--redirected-focus"; +const FOCUSED_VISIBLE_CLASS = "w--redirected-focus-visible"; +export const FormBooleanInput = React.forwardRef(function FormBooleanInput( + { + className = "", + checked = false, + type = "checkbox", + inputType, + customClassName, + ...props + }, + ref +) { + const [isChecked, setIsChecked] = React.useState(checked); + const [isFocused, setIsFocused] = React.useState(false); + const [isFocusedVisible, setIsFocusedVisible] = React.useState(false); + const wasClicked = React.useRef(false); + const inputProps = { + checked: isChecked, + type, + onChange: (e) => { + if (props.onChange) props.onChange(e); + setIsChecked((prevIsChecked) => !prevIsChecked); + }, + onClick: (e) => { + if (props.onClick) props.onClick(e); + wasClicked.current = true; + }, + onFocus: (e) => { + if (props.onFocus) props.onFocus(e); + setIsFocused(true); + if (!wasClicked.current) { + setIsFocusedVisible(true); + } + }, + onBlur: (e) => { + if (props.onBlur) props.onBlur(e); + setIsFocused(false); + setIsFocusedVisible(false); + wasClicked.current = false; + }, + onKeyDown: onKeyDownInputHandlers, + }; + if (inputType === "custom") { + const pseudoModeClasses = `${isChecked ? ` ${CHECKED_CLASS}` : ""}${ + isFocused ? ` ${FOCUSED_CLASS}` : "" + }${isFocusedVisible ? ` ${FOCUSED_CLASS} ${FOCUSED_VISIBLE_CLASS}` : ""} ${ + customClassName ?? "" + }`; + const currentClassName = `${className}${pseudoModeClasses}`; + return ( + <> +
+ + + ); + } + return ; +}); +export const FormCheckboxInput = React.forwardRef(function FormCheckboxInput( + { className = "", ...props }, + ref +) { + return ( + + ); +}); +export const FormRadioInput = React.forwardRef(function FormRadioInput( + { className = "", ...props }, + ref +) { + return ( + + ); +}); +const MAX_FILE_SIZE_DEFAULT = 10485760; +const FileUploadContext = React.createContext({ + files: null, + error: null, + maxSize: MAX_FILE_SIZE_DEFAULT, + setFiles: () => undefined, + setError: () => undefined, +}); +export const FormFileUploadWrapper = React.forwardRef( + function FormFileUploadWrapper( + { maxSize = MAX_FILE_SIZE_DEFAULT, ...props }, + ref + ) { + const [files, setFiles] = React.useState(null); + const [error, setError] = React.useState(null); + return React.createElement( + FileUploadContext.Provider, + { + value: { files, setFiles, error, setError, maxSize }, + }, + React.createElement(_FormFileUploadWrapper, { ...props, ref }) + ); + } +); +export const _FormFileUploadWrapper = React.forwardRef( + function _FormFileUploadWrapper({ className = "", ...props }, ref) { + return React.createElement("div", { + className: className + " w-file-upload", + ...props, + ref, + }); + } +); +export const FormFileUploadDefault = React.forwardRef( + function FormFileUploadDefault({ className = "", ...props }, ref) { + const { files, error } = React.useContext(FileUploadContext); + return React.createElement("div", { + className: className + " w-file-upload-default", + ...props, + ref, + style: { + ...props.style, + display: !files || error ? "block" : "none", + }, + }); + } +); +export const FormFileUploadInput = React.forwardRef( + function FormFileUploadInput({ className = "", ...props }, ref) { + const { setFiles, setError, maxSize } = React.useContext(FileUploadContext); + return React.createElement("input", { + ...props, + className: className + " w-file-upload-input", + type: "file", + onKeyDown: onKeyDownInputHandlers, + onChange: (e) => { + if (e.target.files) { + if (e.target.files[0] && e.target.files[0].size <= maxSize) { + setError(null); + setFiles(e.target.files); + } else setError("SIZE_ERROR"); + } + }, + ref, + }); + } +); +export const FormFileUploadLabel = React.forwardRef( + function FormFileUploadLabel({ className = "", ...props }, ref) { + return React.createElement("label", { + className: className + " w-file-upload-label", + ...props, + ref, + }); + } +); +export const FormFileUploadText = React.forwardRef(function FormFileUploadText( + { className = "", ...props }, + ref +) { + return React.createElement("div", { + className: className + " w-inline-block", + ...props, + ref, + }); +}); +export const FormFileUploadInfo = React.forwardRef(function FormFileUploadInfo( + { className = "", ...props }, + ref +) { + return React.createElement("div", { + className: className + " w-file-upload-info", + ...props, + ref, + }); +}); +export const FormFileUploadUploading = React.forwardRef( + function FormFileUploadUploading({ className = "", ...props }, ref) { + return React.createElement("div", { + className: className + " w-file-upload-uploading", + style: { ...props.style, display: "none" }, + ...props, + ref, + }); + } +); +export const FormFileUploadUploadingBtn = React.forwardRef( + function FormFileUploadUploadingBtn({ className = "", ...props }, ref) { + return React.createElement("div", { + className: className + " w-file-upload-uploading-btn", + ...props, + ref, + }); + } +); +export const FormFileUploadUploadingIcon = React.forwardRef( + function FormFileUploadUploadingIcon({ className = "", ...props }, ref) { + return React.createElement( + "svg", + { + className: className + " icon w-icon-file-upload-uploading", + ...props, + ref, + }, + <> + + + + + + ); + } +); +export const FormFileUploadSuccess = React.forwardRef( + function FormFileUploadSuccess({ className = "", ...props }, ref) { + const { files, error } = React.useContext(FileUploadContext); + return React.createElement("div", { + className: className + " w-file-upload-success", + ...props, + ref, + style: { + ...props.style, + display: Boolean(files) && !error ? "block" : "none", + }, + }); + } +); +export const FormFileUploadFile = React.forwardRef(function FormFileUploadFile( + { className = "", ...props }, + ref +) { + return React.createElement("div", { + className: className + " w-file-upload-file", + ...props, + ref, + }); +}); +export const FormFileUploadFileName = React.forwardRef( + function FormFileUploadFileName({ className = "", ...props }, ref) { + const { files } = React.useContext(FileUploadContext); + return React.createElement( + "div", + { + className: className + " w-file-upload-file-name", + ...props, + ref, + }, + files && files[0].name + ); + } +); +export const FormFileUploadRemoveLink = React.forwardRef( + function FormFileUploadRemoveLink({ className = "", ...props }, ref) { + const { setFiles } = React.useContext(FileUploadContext); + return React.createElement("div", { + className: className + " w-file-remove-link", + ...props, + ref, + onClick: () => { + setFiles(null); + }, + }); + } +); +export const FormFileUploadError = React.forwardRef( + function FormFileUploadError({ className = "", ...props }, ref) { + const { error } = React.useContext(FileUploadContext); + return React.createElement("div", { + className: className + " w-file-upload-error", + ...props, + ref, + style: { + ...props.style, + display: error ? "block" : "none", + }, + }); + } +); +export const FormFileUploadErrorMsg = React.forwardRef( + function FormFileUploadErrorMsg({ errors, className = "", ...props }, ref) { + const { error } = React.useContext(FileUploadContext); + return React.createElement( + "div", + { + className: className + " w-file-upload-error-msg", + ...props, + ref, + }, + errors[error ?? "GENERIC_ERROR"] + ); + } +); +export const FormButton = React.forwardRef(function FormButton( + { className = "", value, ...props }, + ref +) { + return React.createElement("input", { + ...props, + ref, + type: "submit", + value: value ?? "", + className: className + " w-button", + onKeyDown: onKeyDownInputHandlers, + }); +}); +export const SearchForm = React.forwardRef(function SearchForm(props, ref) { + return React.createElement("form", { ...props, ref }); +}); +export const SearchInput = React.forwardRef(function SearchInput( + { className = "", ...props }, + ref +) { + return React.createElement("input", { + ...props, + type: "text", + className: className + " w-input", + onKeyDown: onKeyDownInputHandlers, + ref, + }); +}); +export const SearchButton = React.forwardRef(function SearchButton( + { value = "", className = "", ...props }, + ref +) { + return React.createElement("input", { + ...props, + type: "submit", + value, + className: className + " w-button", + onKeyDown: onKeyDownInputHandlers, + ref, + }); +}); +export const FormSuccessMessage = React.forwardRef(function FormSuccessMessage( + { className = "", ...props }, + ref +) { + return React.createElement("div", { + className: className + " w-form-done", + ...props, + ref, + }); +}); +export const FormErrorMessage = React.forwardRef(function FormErrorMessage( + { className = "", ...props }, + ref +) { + return React.createElement("div", { + className: className + " w-form-fail", + ...props, + ref, + }); +}); +function hasValue(str) { + if (typeof str !== "string") return false; + return str.replace(/^[s ]+|[s ]+$/g, "").length > 0; +} +export const FormSelect = React.forwardRef(function FormSelect( + { options, className = "", ...props }, + ref +) { + return React.createElement( + "select", + { className: className + " w-select", ...props, ref }, + options.map(({ v, t }, index) => + React.createElement( + "option", + { key: index, value: hasValue(v) ? v : "" }, + hasValue(t) ? t : "" + ) + ) + ); +}); +export const FormReCaptcha = React.forwardRef(function FormReCaptcha( + { siteKey = "", theme = "light", size = "normal" }, + ref +) { + React.useEffect(() => { + loadScript("https://www.google.com/recaptcha/api.js", { + cacheRegex: /(http|https):\/\/(www)?.+\/recaptcha/, + }); + }, []); + return ( +
+ ); +}); diff --git a/demos/react-multi-client/src/devlink/_Builtin/Map.d.ts b/demos/react-multi-client/src/devlink/_Builtin/Map.d.ts new file mode 100644 index 00000000..f5661d5b --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Map.d.ts @@ -0,0 +1,8 @@ +declare global { + interface Window { + google: { + maps: any; + }; + } +} +export declare const MapWidget: any; diff --git a/demos/react-multi-client/src/devlink/_Builtin/Map.jsx b/demos/react-multi-client/src/devlink/_Builtin/Map.jsx new file mode 100644 index 00000000..9d7869fa --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Map.jsx @@ -0,0 +1,91 @@ +import React, { useEffect, useRef } from "react"; +import { cj, loadScript } from "../utils"; +function buildTitle(title, tooltip) { + let markerTitle = "Map pin"; + if (title && tooltip) { + markerTitle = `Map pin on ${title} showing location of ${tooltip}`; + } else if (title && !tooltip) { + markerTitle = `Map pin on ${title}`; + } else if (!title && tooltip) { + markerTitle = `Map pin showing location of ${tooltip}`; + } + return markerTitle; +} +export const MapWidget = React.forwardRef(function MapWidget( + { + apiKey = "", + mapStyle = "roadmap", + zoom = 12, + latlng = "51.511214,-0.119824", + tooltip = "", + title = "", + enableScroll = true, + enableTouch = true, + className = "", + ...props + }, + ref +) { + const mapRef = useRef(null); + React.useImperativeHandle(ref, () => mapRef.current); + useEffect(() => { + const loadMap = () => { + if (!mapRef.current) return; + if (!window?.google?.maps) return; + const { Map, Marker, InfoWindow } = window.google.maps; + const coords = latlng.split(","); + const center = { lat: parseFloat(coords[0]), lng: parseFloat(coords[1]) }; + const map = new Map(mapRef.current, { + zoom, + center, + mapTypeId: mapStyle, + mapTypeControl: false, + panControl: false, + streetViewControl: false, + draggable: enableTouch, + scrollwheel: enableScroll, + zoomControl: true, + }); + const marker = new Marker({ + draggable: false, + position: center, + title: buildTitle(title, tooltip), + map, + }); + if (tooltip) { + new InfoWindow({ + disableAutoPan: true, + content: tooltip, + position: center, + }).open({ anchor: marker, map }); + } + window.google.maps.event.addListener(marker, "click", function () { + window.open(`https://maps.google.com/?z=${zoom}&daddr=${latlng}`); + }); + }; + loadScript( + `https://maps.googleapis.com/maps/api/js?v=3.52.5&key=${apiKey}`, + { + cacheRegex: /maps\.googleapis\.com\/maps\/api\/js\?v=3\.52\.5\&key=/gi, + } + ).then(loadMap); + }, [ + apiKey, + mapStyle, + zoom, + latlng, + tooltip, + title, + enableScroll, + enableTouch, + mapRef, + ]); + return ( +
+ ); +}); diff --git a/demos/react-multi-client/src/devlink/_Builtin/Navbar.d.ts b/demos/react-multi-client/src/devlink/_Builtin/Navbar.d.ts new file mode 100644 index 00000000..3bdef28d --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Navbar.d.ts @@ -0,0 +1,123 @@ +import * as React from "react"; +import { EASING_FUNCTIONS } from "../utils"; +import { LinkProps, TagProps } from "./Basic"; +declare const BREAKPOINTS: { + medium: number; + small: number; + tiny: number; +}; +type NavbarConfig = { + animation: string; + collapse: keyof typeof BREAKPOINTS; + docHeight: boolean; + duration: number; + easing: keyof typeof EASING_FUNCTIONS; + easing2: keyof typeof EASING_FUNCTIONS; + noScroll: boolean; +}; +export declare const NavbarContext: React.Context< + NavbarConfig & { + animDirect: -1 | 1; + animOver: boolean; + getBodyHeight: () => number | void; + getOverlayHeight: () => number | undefined; + isOpen: boolean; + menu: React.MutableRefObject; + root: React.MutableRefObject; + toggleOpen: () => void; + navbarMounted: boolean; + setFocusedLink: React.Dispatch>; + } +>; +type NavbarChildrenType = + | NavbarContainerProps + | NavbarBrandProps + | NavbarMenuProps + | NavbarButtonProps; +type NavbarProps = { + tag: React.ElementType; + config: NavbarConfig; + className?: string; + children?: + | React.ReactElement[] + | React.ReactElement; +}; +export declare const NavbarWrapper: React.ForwardRefExoticComponent< + NavbarProps & React.RefAttributes +>; +type NavbarContainerProps = TagProps & { + toggleOpen: () => void; + isOpen: boolean; + children: React.ReactNode; +}; +export declare const NavbarContainer: React.ForwardRefExoticComponent< + import("./Basic").ElementProps & { + tag?: React.ElementType | undefined; + } & { + children?: React.ReactNode; + } & { + toggleOpen: () => void; + isOpen: boolean; + children: React.ReactNode; + } & React.RefAttributes +>; +type NavbarBrandProps = LinkProps; +export declare const NavbarBrand: React.ForwardRefExoticComponent< + import("./Basic").ElementProps<"a"> & { + options?: + | { + href: string; + target?: "_self" | "_blank" | undefined; + preload?: "none" | "prerender" | "prefetch" | undefined; + } + | undefined; + className?: string | undefined; + button?: boolean | undefined; + block?: string | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +type NavbarMenuProps = React.PropsWithChildren<{ + tag?: React.ElementType; + className?: string; + isOpen?: boolean; +}>; +export declare const NavbarMenu: React.ForwardRefExoticComponent< + { + tag?: React.ElementType | undefined; + className?: string | undefined; + isOpen?: boolean | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const NavbarLink: React.ForwardRefExoticComponent< + import("./Basic").ElementProps<"a"> & { + options?: + | { + href: string; + target?: "_self" | "_blank" | undefined; + preload?: "none" | "prerender" | "prefetch" | undefined; + } + | undefined; + className?: string | undefined; + button?: boolean | undefined; + block?: string | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +type NavbarButtonProps = React.PropsWithChildren<{ + tag?: React.ElementType; + className?: string; +}>; +export declare const NavbarButton: React.ForwardRefExoticComponent< + { + tag?: React.ElementType | undefined; + className?: string | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export {}; diff --git a/demos/react-multi-client/src/devlink/_Builtin/Navbar.jsx b/demos/react-multi-client/src/devlink/_Builtin/Navbar.jsx new file mode 100644 index 00000000..91e71bed --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Navbar.jsx @@ -0,0 +1,369 @@ +import * as React from "react"; +import { + EASING_FUNCTIONS, + KEY_CODES, + cj, + debounce, + extractElement, + isServer, + useLayoutEffect, + useResizeObserver, +} from "../utils"; +import { Link, Container } from "./Basic"; +const BREAKPOINTS = { + medium: 991, + small: 767, + tiny: 479, +}; +function getLinksList(root) { + return root.querySelectorAll(".w-nav-menu .w-nav-link"); +} +export const NavbarContext = React.createContext({ + animDirect: 1, + animOver: false, + animation: "animation", + collapse: "medium", + docHeight: false, + duration: 400, + easing2: "ease", + easing: "ease", + getBodyHeight: () => undefined, + getOverlayHeight: () => { + return undefined; + }, + isOpen: false, + noScroll: false, + toggleOpen: () => undefined, + navbarMounted: false, + menu: undefined, + root: undefined, + setFocusedLink: () => undefined, +}); +function getAnimationKeyframes({ axis = "Y", start, end }) { + const t = `translate${axis}`; + return [{ transform: `${t}(${start}px)` }, { transform: `${t}(${end}px)` }]; +} +export const NavbarWrapper = React.forwardRef(function NavbarWrapper( + props, + ref +) { + const { animation, docHeight, easing, easing2, duration, noScroll } = + props.config; + const root = React.useRef(null); + const menu = React.useRef(null); + const animOver = /^over/.test(animation); + const animDirect = /left$/.test(animation) ? -1 : 1; + const [focusedLink, setFocusedLink] = React.useState(-1); + React.useImperativeHandle(ref, () => root.current); + const getBodyHeight = React.useCallback(() => { + if (isServer) return; + return docHeight + ? document.documentElement.scrollHeight + : document.body.scrollHeight; + }, [docHeight]); + const getOverlayHeight = React.useCallback(() => { + if (isServer || !root.current) return; + let height = getBodyHeight(); + if (!height) return; + const style = getComputedStyle(root.current); + if (!animOver && style.position !== "fixed") { + height -= root.current.offsetHeight; + } + return height; + }, [animOver, getBodyHeight]); + const getOffsetHeight = React.useCallback(() => { + if (!root.current || !menu.current) return 0; + return root.current.offsetHeight + menu.current.offsetHeight; + }, []); + const [isOpen, setIsOpen] = React.useState(false); + const toggleOpen = debounce(() => { + if (!menu.current) return; + if (isOpen) { + const keyframes = animOver + ? getAnimationKeyframes({ + axis: "X", + start: 0, + end: animDirect * menu.current.offsetWidth, + }) + : getAnimationKeyframes({ start: 0, end: -getOffsetHeight() }); + const anim = menu.current.animate(keyframes, { + easing: EASING_FUNCTIONS[easing2] ?? "ease", + duration, + fill: "forwards", + }); + anim.onfinish = () => { + setIsOpen(!isOpen); + }; + return; + } + setFocusedLink(-1); + setIsOpen(!isOpen); + }); + useLayoutEffect(() => { + if (!menu.current) return; + if (isOpen) { + const keyframes = animOver + ? getAnimationKeyframes({ + axis: "X", + start: animDirect * menu.current.offsetWidth, + end: 0, + }) + : getAnimationKeyframes({ start: -getOffsetHeight(), end: 0 }); + menu.current.animate(keyframes, { + easing: EASING_FUNCTIONS[easing] ?? "ease", + duration, + fill: "forwards", + }); + } + }, [ + animDirect, + animOver, + duration, + easing, + getBodyHeight, + getOffsetHeight, + isOpen, + ]); + useLayoutEffect(() => { + if (isOpen && noScroll) { + document.body.style.overflowY = "hidden"; + } else { + document.body.style.overflowY = ""; + } + return () => { + document.body.style.overflowY = ""; + }; + }, [isOpen, noScroll]); + const closeOnResize = React.useCallback(() => setIsOpen(false), [setIsOpen]); + useResizeObserver(root, closeOnResize, { onlyWidth: true }); + React.useEffect(() => { + if (root.current) { + const links = getLinksList(root.current); + links[focusedLink ?? 0]?.focus(); + } + }, [focusedLink]); + return ( + + + + ); +}); +const maybeExtractChildMenu = (children, isOpen) => { + if (!isOpen) return { childMenu: null, rest: children }; + const { extracted, tree } = extractElement( + React.Children.toArray(children), + NavbarMenu + ); + return { childMenu: extracted, rest: tree }; +}; +function Navbar({ tag = "div", className = "", children, config, ...props }) { + const { root, collapse, setFocusedLink } = React.useContext(NavbarContext); + const [shouldExtractMenu, setShouldExtractMenu] = React.useState(true); + const extractMenuCallback = React.useCallback( + (entry) => { + setShouldExtractMenu(entry.contentRect.width <= BREAKPOINTS[collapse]); + }, + [setShouldExtractMenu] + ); + const bodyRef = React.useRef( + typeof document !== "undefined" ? document.body : null + ); + useResizeObserver(bodyRef, extractMenuCallback); + const { childMenu, rest } = React.useMemo( + () => maybeExtractChildMenu(children, shouldExtractMenu), + [children, shouldExtractMenu] + ); + const handleFocus = (e) => { + const linkList = root.current ? Array.from(getLinksList(root.current)) : []; + const linkAmount = linkList.length; + switch (e.key) { + case KEY_CODES.ARROW_LEFT: + case KEY_CODES.ARROW_UP: { + e.preventDefault(); + setFocusedLink((prev) => Math.max(prev - 1, 0)); + break; + } + case KEY_CODES.ARROW_RIGHT: + case KEY_CODES.ARROW_DOWN: { + e.preventDefault(); + setFocusedLink((prev) => Math.min(prev + 1, linkAmount - 1)); + break; + } + case KEY_CODES.HOME: { + e.preventDefault(); + setFocusedLink(0); + break; + } + case KEY_CODES.END: { + e.preventDefault(); + setFocusedLink(linkAmount - 1); + break; + } + case KEY_CODES.TAB: { + setTimeout(() => { + setFocusedLink( + linkList.findIndex((link) => link === document.activeElement) + ); + }, 0); + break; + } + case KEY_CODES.SPACE: { + e.preventDefault(); + break; + } + default: { + return; + } + } + }; + return React.createElement( + tag, + { + ...props, + className: cj(className, "w-nav"), + "data-collapse": config.collapse, + "data-animation": config.animation, + ref: root, + onKeyDown: handleFocus, + }, + <> + {rest} + + {childMenu} + + ); +} +function NavbarOverlay({ children }) { + const { isOpen, getOverlayHeight, toggleOpen } = + React.useContext(NavbarContext); + const overlayToggleOpen = React.useCallback( + (e) => { + if (e.target === e.currentTarget) { + toggleOpen(); + } + }, + [toggleOpen] + ); + const overlayHeight = getOverlayHeight(); + return ( +
+ {children} +
+ ); +} +export const NavbarContainer = React.forwardRef(function NavbarContainer( + { children, ...props }, + ref +) { + const innerRef = React.useRef(null); + const { isOpen } = React.useContext(NavbarContext); + React.useImperativeHandle(ref, () => innerRef.current); + const updateLinkStyles = React.useCallback( + (entry) => { + const { maxWidth: containerMaxWidth } = getComputedStyle(entry.target); + document + .querySelectorAll(".w-nav-menu>.w-dropdown,.w-nav-menu>.w-nav-link") + .forEach((node) => { + if (!(node instanceof HTMLElement)) return; + if (!isOpen) { + node.style.maxWidth = ""; + return; + } + const { maxWidth } = getComputedStyle(node); + node.style.maxWidth = + !maxWidth || maxWidth === "none" || maxWidth === containerMaxWidth + ? containerMaxWidth + : ""; + }); + }, + [isOpen] + ); + useResizeObserver(innerRef, updateLinkStyles); + return ( + + {children} + + ); +}); +export const NavbarBrand = React.forwardRef(function NavbarBrand( + { className = "", ...props }, + ref +) { + return ; +}); +export const NavbarMenu = React.forwardRef(function NavbarMenu( + { tag = "nav", className = "", ...props }, + ref +) { + const { getBodyHeight, animOver, isOpen, menu } = + React.useContext(NavbarContext); + React.useImperativeHandle(ref, () => menu.current); + return React.createElement(tag, { + ...props, + className: cj(className, "w-nav-menu"), + ...(isOpen ? { "data-nav-menu-open": "" } : {}), + style: animOver ? { height: getBodyHeight() } : {}, + ref: menu, + }); +}); +export const NavbarLink = React.forwardRef(function NavbarLink( + { className = "", ...props }, + ref +) { + const { isOpen } = React.useContext(NavbarContext); + return ( + + ); +}); +export const NavbarButton = React.forwardRef(function NavbarButton( + { tag = "div", className = "", ...props }, + ref +) { + const { isOpen, toggleOpen } = React.useContext(NavbarContext); + return React.createElement(tag, { + ...props, + "aria-label": "menu", + "aria-expanded": isOpen ? "true" : "false", + "aria-haspopup": "menu", + "aria-controls": "w-nav-overlay", + role: "button", + tabIndex: 0, + className: cj(className, "w-nav-button", isOpen && "w--open"), + onClick: toggleOpen, + onKeyDown: (e) => { + if (e.key === KEY_CODES.ENTER || e.key === KEY_CODES.SPACE) { + e.preventDefault(); + toggleOpen(); + } + }, + ref, + }); +}); diff --git a/demos/react-multi-client/src/devlink/_Builtin/Slider.d.ts b/demos/react-multi-client/src/devlink/_Builtin/Slider.d.ts new file mode 100644 index 00000000..6e748ec6 --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Slider.d.ts @@ -0,0 +1,100 @@ +import * as React from "react"; +import { EASING_FUNCTIONS } from "../utils"; +type SliderConfig = { + navSpacing: number; + navShadow: boolean; + autoplay: boolean; + delay: number; + iconArrows: boolean; + animation: "slide" | "cross" | "outin" | "fade" | "over"; + navNumbers: boolean; + easing: keyof typeof EASING_FUNCTIONS; + navRound: boolean; + hideArrows: boolean; + disableSwipe: boolean; + duration: number; + infinite: boolean; + autoMax: number; + navInvert: boolean; +}; +type SlideState = { + current: number; + previous: number; +}; +export declare const SliderContext: React.Context< + SliderConfig & { + slideAmount: number; + setSlideAmount: React.Dispatch>; + slide: SlideState; + setCurrentSlide: (current: number) => void; + goToNextSlide: () => void; + goToPreviousSlide: () => void; + isAutoplayPaused: boolean; + setAutoplayPause: React.Dispatch>; + } +>; +type SliderChildrenType = + | SliderSlideProps + | SliderArrowProps + | SliderNavProps + | SliderMaskProps; +export declare const SliderWrapper: React.ForwardRefExoticComponent< + SliderConfig & { + className?: string | undefined; + children?: + | React.ReactElement< + SliderChildrenType, + string | React.JSXElementConstructor + > + | React.ReactElement< + SliderChildrenType, + string | React.JSXElementConstructor + >[] + | undefined; + } & React.RefAttributes +>; +type SliderMaskProps = React.PropsWithChildren<{ + className?: string; +}>; +export declare const SliderMask: React.ForwardRefExoticComponent< + { + className?: string | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +type SliderSlideProps = React.PropsWithChildren<{ + style?: React.CSSProperties; + tag?: string; + className?: string; + index: number; +}>; +export declare const SliderSlide: React.ForwardRefExoticComponent< + { + style?: React.CSSProperties | undefined; + tag?: string | undefined; + className?: string | undefined; + index: number; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +type SliderArrowProps = React.PropsWithChildren<{ + className?: string; + dir: "left" | "right"; +}>; +export declare const SliderArrow: React.ForwardRefExoticComponent< + { + className?: string | undefined; + dir: "left" | "right"; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +type SliderNavProps = { + className?: string; +}; +export declare const SliderNav: React.ForwardRefExoticComponent< + SliderNavProps & React.RefAttributes +>; +export {}; diff --git a/demos/react-multi-client/src/devlink/_Builtin/Slider.jsx b/demos/react-multi-client/src/devlink/_Builtin/Slider.jsx new file mode 100644 index 00000000..592b4ba4 --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Slider.jsx @@ -0,0 +1,458 @@ +import * as React from "react"; +import { IXContext, triggerIXEvent } from "../interactions"; +import { EASING_FUNCTIONS, KEY_CODES, cj, debounce } from "../utils"; +const DEFAULT_SLIDER_CONFIG = { + navSpacing: 3, + navShadow: false, + autoplay: false, + delay: 4000, + iconArrows: true, + animation: "slide", + navNumbers: true, + easing: "ease", + navRound: true, + hideArrows: false, + disableSwipe: false, + duration: 500, + infinite: true, + autoMax: 0, + navInvert: false, +}; +export const SliderContext = React.createContext({ + ...DEFAULT_SLIDER_CONFIG, + slideAmount: 0, + setSlideAmount: () => undefined, + setCurrentSlide: () => undefined, + goToNextSlide: () => undefined, + goToPreviousSlide: () => undefined, + slide: { current: 0, previous: 0 }, + isAutoplayPaused: false, + setAutoplayPause: () => undefined, +}); +function useSwipe({ onSwipeLeft, onSwipeRight, config }) { + const SWIPE_DELTA = 150; + const [touchStart, setTouchStart] = React.useState(0); + const [touchEnd, setTouchEnd] = React.useState(0); + const handleTouchStart = (e) => { + setTouchStart(e.touches[0].clientX); + }; + const handleTouchMove = (e) => { + setTouchEnd(e.touches[0].clientX); + }; + const handleTouchEnd = () => { + if (config?.disableSwipe) return; + if (touchStart - touchEnd > SWIPE_DELTA) { + onSwipeLeft(); + } + if (touchStart - touchEnd < -SWIPE_DELTA) { + onSwipeRight(); + } + }; + return { + onTouchStart: handleTouchStart, + onTouchMove: handleTouchMove, + onTouchEnd: handleTouchEnd, + }; +} +export const SliderWrapper = React.forwardRef(function SlideWrapper( + { className = "", ...props }, + ref +) { + const [slideAmount, setSlideAmount] = React.useState(0); + const [selectedSlide, setSelectedSlide] = React.useState(0); + const [prevSelectedSlide, setPrevSelectedSlide] = React.useState(0); + const [isAutoplayPaused, setAutoplayPause] = React.useState(false); + const setCurrentSlide = debounce((value) => { + setSelectedSlide((prev) => { + setPrevSelectedSlide(prev); + return value; + }); + }); + const goToNextSlide = debounce(() => { + if (selectedSlide === slideAmount - 1) { + setCurrentSlide(0); + } else { + setCurrentSlide(selectedSlide + 1); + } + }); + const goToPreviousSlide = debounce(() => { + if (selectedSlide === 0) { + setCurrentSlide(slideAmount - 1); + } else { + setCurrentSlide(selectedSlide - 1); + } + }); + const swipeHandlers = useSwipe({ + onSwipeLeft: goToNextSlide, + onSwipeRight: goToPreviousSlide, + }); + return ( + +
+ {props.children} +
+
+ ); +}); +function useAutoplay() { + const { + autoplay, + delay, + autoMax, + isAutoplayPaused, + setAutoplayPause, + goToNextSlide, + } = React.useContext(SliderContext); + const [autoMaxCount, setAutoMaxCount] = React.useState(0); + const autoMaxReached = React.useMemo( + () => autoMaxCount >= autoMax && autoMax > 0, + [autoMax, autoMaxCount] + ); + React.useEffect(() => { + const shouldAutoplay = autoplay && !autoMaxReached && !isAutoplayPaused; + if (shouldAutoplay) { + const interval = setInterval(() => { + setAutoMaxCount((prev) => prev + 1); + goToNextSlide(); + }, delay); + return () => clearInterval(interval); + } + }, [autoplay, delay, goToNextSlide, autoMaxReached, isAutoplayPaused]); + const resumeAutoplay = () => setAutoplayPause(true); + const pauseAutoplay = () => setAutoplayPause(false); + return { resumeAutoplay, pauseAutoplay }; +} +export const SliderMask = React.forwardRef(function SliderMask( + { className = "", children, ...props }, + ref +) { + const { setSlideAmount } = React.useContext(SliderContext); + const [isHovered, setHovered] = React.useState(false); + const [slides, setSlides] = React.useState([]); + const { resumeAutoplay, pauseAutoplay } = useAutoplay(); + React.useEffect(() => { + const extractNonFragmentChildren = (_children) => { + const childrenList = React.Children.toArray(_children).filter((child) => + React.isValidElement(child) + ); + if ( + childrenList.length === 1 && + childrenList[0]?.type === React.Fragment + ) { + return extractNonFragmentChildren(childrenList[0].props.children); + } else { + return childrenList; + } + }; + const _slides = extractNonFragmentChildren(children); + setSlideAmount(_slides.length); + setSlides(_slides); + }, [children]); + return ( +
{ + pauseAutoplay(); + setHovered(true); + }} + onMouseLeave={() => { + resumeAutoplay(); + setHovered(false); + }} + onFocus={() => pauseAutoplay()} + onBlur={() => resumeAutoplay()} + ref={ref} + > + {slides.map((child, index) => { + return React.cloneElement(child, { + ...child.props, + index, + }); + })} +
+
+ ); +}); +export const SliderSlide = React.forwardRef(function SliderSlide( + { tag = "div", className = "", style = {}, index, ...props }, + ref +) { + const { + animation, + duration, + easing, + slide: { current, previous }, + slideAmount, + } = React.useContext(SliderContext); + const { restartEngine } = React.useContext(IXContext); + React.useEffect(() => { + restartEngine && restartEngine(); + }, [restartEngine]); + const isSlideActive = current === index; + const isSlidePrevious = previous === index; + const animationStyle = React.useMemo(() => { + const base = { + transform: `translateX(-${current * 100}%)`, + transition: `transform ${duration}ms ${EASING_FUNCTIONS[easing]} 0s`, + }; + if (animation === "slide") { + return base; + } + if (animation === "cross") { + return { + ...base, + opacity: isSlideActive ? 1 : 0, + transition: `opacity ${duration}ms ${ + EASING_FUNCTIONS[easing] + } 0s, transform 1ms linear ${isSlideActive ? 0 : duration}ms`, + }; + } + if (animation === "outin") { + return { + ...base, + opacity: isSlideActive ? 1 : 0, + transition: `opacity ${duration / 2}ms ${EASING_FUNCTIONS[easing]} ${ + isSlidePrevious ? 0 : duration / 2 + }ms, transform 1ms linear ${isSlidePrevious ? duration / 2 : 0}ms`, + }; + } + if (animation === "fade") { + return { + ...base, + opacity: isSlideActive ? 1 : 0, + transition: `opacity ${duration}ms ${ + EASING_FUNCTIONS[easing] + } 0s, transform 1ms linear ${isSlideActive ? 0 : duration}ms`, + }; + } + if (animation === "over") { + return { + ...base, + transition: `transform ${duration}ms ${EASING_FUNCTIONS[easing]} ${ + isSlidePrevious ? duration : 0 + }ms`, + zIndex: isSlideActive ? 1 : 0, + }; + } + return base; + }, [animation, duration, easing, current, isSlideActive, isSlidePrevious]); + const innerRef = React.useCallback( + (node) => { + triggerIXEvent(node, isSlideActive); + if (ref) { + if (typeof ref === "function") { + ref(node); + } else { + ref.current = node; + } + } + }, + [isSlideActive, ref] + ); + return React.createElement(tag, { + ...props, + className: cj(className, "w-slide"), + style: { ...style, ...animationStyle }, + "aria-label": `${index + 1} of ${slideAmount}`, + role: "group", + ref: innerRef, + "aria-hidden": !isSlideActive ? "true" : "false", + }); +}); +export const SliderArrow = React.forwardRef(function SliderArrow( + { className = "", dir = "left", children, ...props }, + ref +) { + const { + goToNextSlide, + goToPreviousSlide, + hideArrows, + slideAmount, + slide: { current }, + } = React.useContext(SliderContext); + const handleSlideChange = debounce(() => { + if (dir === "left") { + goToPreviousSlide(); + } else { + goToNextSlide(); + } + }); + const isHidden = React.useMemo(() => { + if (dir === "left" && hideArrows && current === 0) return true; + if (dir === "right" && hideArrows && current === slideAmount - 1) + return true; + return false; + }, [dir, hideArrows, current, slideAmount]); + return ( +
{ + e.stopPropagation(); + if (e.key === KEY_CODES.ENTER || e.key === KEY_CODES.SPACE) { + e.preventDefault(); + handleSlideChange(); + } + }} + role="button" + tabIndex={0} + className={cj(className, `w-slider-arrow-${dir}`)} + aria-label={`${dir === "left" ? "previous" : "next"} slide`} + style={{ display: isHidden ? "none" : "block" }} + ref={ref} + > + {children} +
+ ); +}); +export const SliderNav = React.forwardRef(function SliderNav( + { className = "", ...props }, + ref +) { + const { + slideAmount, + navInvert, + navNumbers, + navRound, + navShadow, + setAutoplayPause, + setCurrentSlide, + } = React.useContext(SliderContext); + const [focusedDot, setFocusedDot] = React.useState(null); + const handleFocus = (e) => { + switch (e.key) { + case KEY_CODES.ENTER: + case KEY_CODES.SPACE: { + e.preventDefault(); + if (focusedDot !== null) { + setCurrentSlide(focusedDot); + } + break; + } + case KEY_CODES.ARROW_LEFT: + case KEY_CODES.ARROW_UP: { + e.preventDefault(); + setFocusedDot((prev) => Math.max((prev ?? 0) - 1, 0)); + break; + } + case KEY_CODES.ARROW_RIGHT: + case KEY_CODES.ARROW_DOWN: { + e.preventDefault(); + setFocusedDot((prev) => Math.min((prev ?? 0) + 1, slideAmount - 1)); + break; + } + case KEY_CODES.HOME: { + e.preventDefault(); + setFocusedDot(0); + break; + } + case KEY_CODES.END: { + e.preventDefault(); + setFocusedDot(slideAmount - 1); + break; + } + default: { + return; + } + } + }; + const dots = [...Array(slideAmount).keys()].map((_, i) => { + return ( + + ); + }); + return ( +
{ + e.stopPropagation(); + setAutoplayPause(true); + }} + onBlur={() => { + setAutoplayPause(false); + }} + onMouseLeave={(e) => e.stopPropagation()} + className={cj( + className, + `w-slider-nav ${navInvert ? "w-slider-nav-invert" : ""} ${ + navShadow ? "w-shadow" : "" + } ${navRound ? "w-round" : ""} ${navNumbers ? "w-num" : ""}` + )} + ref={ref} + > + {dots} +
+ ); +}); +const SliderDot = React.forwardRef(function SliderDot( + { index, focusedDot, handleFocus, setFocusedDot }, + ref +) { + const { + slideAmount, + navSpacing, + navNumbers, + slide: { current: selectedSlide }, + setCurrentSlide, + } = React.useContext(SliderContext); + const innerRef = React.useRef(null); + React.useImperativeHandle(ref, () => innerRef.current); + React.useEffect(() => { + if (focusedDot === index) { + innerRef.current?.focus(); + } + }, [focusedDot, index]); + const isSlideActive = selectedSlide === index; + const label = navNumbers ? index + 1 : ""; + return ( +
{ + e.stopPropagation(); + setFocusedDot(index); + setCurrentSlide(index); + }} + ref={innerRef} + onKeyDown={handleFocus} + > + {label} +
+ ); +}); diff --git a/demos/react-multi-client/src/devlink/_Builtin/Tabs.d.ts b/demos/react-multi-client/src/devlink/_Builtin/Tabs.d.ts new file mode 100644 index 00000000..33e356cf --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Tabs.d.ts @@ -0,0 +1,69 @@ +import * as React from "react"; +import { EASING_FUNCTIONS } from "../utils"; +import { Props } from "./Basic"; +export declare const TabsWrapper: React.ForwardRefExoticComponent< + import("./Basic").ElementProps<"div"> & { + current: string; + easing: keyof typeof EASING_FUNCTIONS; + fadeIn: number; + fadeOut: number; + children?: + | React.ReactElement< + TabsMenuProps | TabsContentProps, + string | React.JSXElementConstructor + > + | React.ReactElement< + TabsMenuProps | TabsContentProps, + string | React.JSXElementConstructor + >[] + | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +type TabsMenuProps = { + tag?: React.ElementType; + className?: string; + children?: React.ReactElement[]; +}; +export declare const TabsMenu: React.ForwardRefExoticComponent< + TabsMenuProps & React.RefAttributes +>; +type TabsLinkProps = Props< + "a", + { + "data-w-tab": string; + } +>; +export declare const TabsLink: React.ForwardRefExoticComponent< + import("./Basic").ElementProps<"a"> & { + "data-w-tab": string; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +type TabsContentProps = { + tag?: React.ElementType; + className?: string; + children?: + | React.ReactElement[] + | React.ReactElement; +}; +export declare const TabsContent: React.ForwardRefExoticComponent< + TabsContentProps & React.RefAttributes +>; +type TabsPaneProps = React.PropsWithChildren<{ + tag?: React.ElementType; + className?: string; + "data-w-tab": string; +}>; +export declare const TabsPane: React.ForwardRefExoticComponent< + { + tag?: React.ElementType | undefined; + className?: string | undefined; + "data-w-tab": string; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export {}; diff --git a/demos/react-multi-client/src/devlink/_Builtin/Tabs.jsx b/demos/react-multi-client/src/devlink/_Builtin/Tabs.jsx new file mode 100644 index 00000000..dd80e981 --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Tabs.jsx @@ -0,0 +1,172 @@ +import * as React from "react"; +import { triggerIXEvent } from "../interactions"; +import { cj, debounce, EASING_FUNCTIONS, useLayoutEffect } from "../utils"; +const tabsContext = React.createContext({ + current: "", + onTabClick: () => undefined, + onLinkKeyDown: () => undefined, +}); +export const TabsWrapper = React.forwardRef(function TabsWrapper( + { + className = "", + fadeIn, + fadeOut, + easing, + current: initialCurrent, + ...props + }, + ref +) { + const [current, setCurrent] = React.useState(""); + const changeTab = React.useCallback( + (next) => { + function updateTab() { + setCurrent(() => { + const nextTabHeader = document.querySelector( + `.w-tab-link[data-w-tab="${next}"]` + ); + nextTabHeader?.focus(); + return next; + }); + } + const currentTab = document.querySelector( + `.w-tab-pane[data-w-tab="${current}"]` + ); + const nextTab = document.querySelector( + `.w-tab-pane[data-w-tab="${next}"]` + ); + const easingFn = EASING_FUNCTIONS[easing] ?? "ease"; + const animation = currentTab?.animate([{ opacity: 1 }, { opacity: 0 }], { + duration: fadeOut, + fill: "forwards", + easing: easingFn, + }); + if (animation) { + animation.onfinish = () => { + updateTab(); + nextTab?.animate([{ opacity: 0 }, { opacity: 1 }], { + duration: fadeIn, + fill: "forwards", + easing: easingFn, + }); + }; + } else { + updateTab(); + } + }, + [current, easing, fadeIn, fadeOut] + ); + const firstRender = React.useRef(true); + useLayoutEffect(() => { + if (!firstRender.current) return; + firstRender.current = false; + setTimeout(() => { + changeTab(initialCurrent); + }, 1); + }, [changeTab, initialCurrent]); + const onTabClick = debounce(changeTab); + const onLinkKeyDown = debounce((event) => { + event.preventDefault(); + const currentTab = document.querySelector( + `.w-tab-pane[data-w-tab="${current}"]` + ); + const allTabs = document.querySelectorAll(".w-tab-pane"); + const firstTab = allTabs[0]; + const lastTab = allTabs[allTabs.length - 1]; + const nextTab = (() => { + switch (event.key) { + case "ArrowUp": + case "ArrowLeft": + return currentTab.previousElementSibling ?? lastTab; + case "ArrowDown": + case "ArrowRight": + return currentTab.nextElementSibling ?? firstTab; + case "Home": + return firstTab; + case "End": + return lastTab; + } + })(); + if (nextTab) changeTab(nextTab.getAttribute("data-w-tab")); + }); + return ( + +
+ + ); +}); +export const TabsMenu = React.forwardRef(function TabsMenu( + { tag = "div", className = "", ...props }, + ref +) { + return React.createElement(tag, { + ...props, + className: cj(className, "w-tab-menu"), + role: "tablist", + ref, + }); +}); +export const TabsLink = React.forwardRef(function TabsLink( + { className = "", children, ...props }, + ref +) { + const { current, onTabClick, onLinkKeyDown } = React.useContext(tabsContext); + const isCurrent = current === props["data-w-tab"]; + const innerRef = React.useCallback( + (node) => { + if (!node) return; + triggerIXEvent(node, isCurrent); + if (ref) { + if (typeof ref === "function") { + ref(node); + } else { + ref.current = node; + } + } + }, + [isCurrent, ref] + ); + return ( + onTabClick(props["data-w-tab"])} + onKeyDown={onLinkKeyDown} + role="tab" + tabIndex={isCurrent ? 0 : -1} + aria-selected={isCurrent} + aria-controls={props["data-w-tab"]} + > + {children} + + ); +}); +export const TabsContent = React.forwardRef(function TabsContent( + { tag = "div", className = "", ...props }, + ref +) { + return React.createElement(tag, { + ...props, + className: cj(className, "w-tab-content"), + ref, + }); +}); +export const TabsPane = React.forwardRef(function TabsPane( + { tag = "div", className = "", ...props }, + ref +) { + const { current } = React.useContext(tabsContext); + const isCurrent = current === props["data-w-tab"]; + return React.createElement(tag, { + ...props, + className: cj(className, "w-tab-pane", isCurrent && "w--tab-active"), + role: "tabpanel", + "aria-labelledby": props["data-w-tab"], + ref, + }); +}); diff --git a/demos/react-multi-client/src/devlink/_Builtin/Twitter.d.ts b/demos/react-multi-client/src/devlink/_Builtin/Twitter.d.ts new file mode 100644 index 00000000..292b3f1f --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Twitter.d.ts @@ -0,0 +1,20 @@ +import * as React from "react"; +type TwitterSize = "m" | "l"; +type TwitterMode = "follow" | "tweet"; +declare global { + interface Window { + twttr: any; + } +} +export declare const Twitter: React.ForwardRefExoticComponent< + { + className?: string | undefined; + mode?: TwitterMode | undefined; + url?: string | undefined; + text?: string | undefined; + size?: TwitterSize | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export {}; diff --git a/demos/react-multi-client/src/devlink/_Builtin/Twitter.jsx b/demos/react-multi-client/src/devlink/_Builtin/Twitter.jsx new file mode 100644 index 00000000..7cf13bf1 --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Twitter.jsx @@ -0,0 +1,57 @@ +import * as React from "react"; +import { isUrl, loadScript } from "../utils"; +const modeDict = { + follow: "createFollowButton", + tweet: "createShareButton", +}; +const sizeDict = { + m: "medium", + l: "large", +}; +export const Twitter = React.forwardRef(function Twitter( + { + className = "", + url = "https://webflow.com", + mode = "tweet", + size = "m", + text = "Check out this site", + ...props + }, + ref +) { + const innerRef = React.useRef(null); + React.useImperativeHandle(ref, () => innerRef.current); + if (!isUrl(url)) { + if (mode === "tweet") { + url = "https://webflow.com/"; + } else if (mode === "follow") { + url = "webflow"; + } + } + React.useEffect(() => { + let isComponentMounted = true; + loadScript("https://platform.twitter.com/widgets.js").then(() => { + if (isComponentMounted) { + if (window.twttr) { + const twitterButtonOption = window.twttr.widgets[modeDict[mode]]; + if (twitterButtonOption) { + twitterButtonOption(url, innerRef?.current, { + size: sizeDict[size], + text, + }); + } + } + } + }); + return () => { + isComponentMounted = false; + }; + }, []); + return ( +
+ ); +}); diff --git a/demos/react-multi-client/src/devlink/_Builtin/Typography.d.ts b/demos/react-multi-client/src/devlink/_Builtin/Typography.d.ts new file mode 100644 index 00000000..839794f2 --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Typography.d.ts @@ -0,0 +1,56 @@ +import * as React from "react"; +export declare const Heading: React.ForwardRefExoticComponent< + { + tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | undefined; + } & { + children?: React.ReactNode; + } & React.HTMLAttributes & + React.RefAttributes +>; +export declare const Paragraph: React.ForwardRefExoticComponent< + import("./Basic").ElementProps<"p"> & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const Emphasized: React.ForwardRefExoticComponent< + import("./Basic").ElementProps<"em"> & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const Strong: React.ForwardRefExoticComponent< + import("./Basic").ElementProps<"strong"> & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const Figure: React.ForwardRefExoticComponent< + import("./Basic").ElementProps<"figure"> & { + children?: React.ReactNode; + } & { + figure: { + align: string; + type: string; + }; + } & React.RefAttributes +>; +export declare const Figcaption: React.ForwardRefExoticComponent< + import("./Basic").ElementProps<"figcaption"> & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const Subscript: React.ForwardRefExoticComponent< + import("./Basic").ElementProps<"sub"> & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const Superscript: React.ForwardRefExoticComponent< + import("./Basic").ElementProps<"sup"> & { + children?: React.ReactNode; + } & React.RefAttributes +>; +export declare const RichText: React.ForwardRefExoticComponent< + import("./Basic").ElementProps<"div"> & { + tag?: React.ElementType | undefined; + } & { + children?: React.ReactNode; + } & React.RefAttributes +>; diff --git a/demos/react-multi-client/src/devlink/_Builtin/Typography.jsx b/demos/react-multi-client/src/devlink/_Builtin/Typography.jsx new file mode 100644 index 00000000..9a333297 --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Typography.jsx @@ -0,0 +1,57 @@ +import * as React from "react"; +export const Heading = React.forwardRef(function Heading( + { tag = "h1", ...props }, + ref +) { + return React.createElement(tag, { + ...props, + ref, + }); +}); +export const Paragraph = React.forwardRef(function Paragraph(props, ref) { + return React.createElement("p", { + ...props, + ref, + }); +}); +export const Emphasized = React.forwardRef(function Emphasized(props, ref) { + return ; +}); +export const Strong = React.forwardRef(function Strong(props, ref) { + return React.createElement("strong", { + ...props, + ref, + }); +}); +export const Figure = React.forwardRef(function Figure( + { className = "", figure, ...props }, + ref +) { + const { type, align } = figure; + if (align) { + className += `w-richtext-align-${align} `; + } + if (type) { + className += `w-richtext-align-${type} `; + } + return
; +}); +export const Figcaption = React.forwardRef(function Figcaption(props, ref) { + return
; +}); +export const Subscript = React.forwardRef(function Subscript(props, ref) { + return ; +}); +export const Superscript = React.forwardRef(function Superrscript(props, ref) { + return ; +}); +export const RichText = React.forwardRef(function RichText( + { tag = "div", className = "", ...props }, + ref +) { + return React.createElement(tag, { + className: className + " w-richtext", + ...props, + ref, + }); +}); diff --git a/demos/react-multi-client/src/devlink/_Builtin/Video.d.ts b/demos/react-multi-client/src/devlink/_Builtin/Video.d.ts new file mode 100644 index 00000000..d90e0b0a --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Video.d.ts @@ -0,0 +1 @@ +export declare const Video: any; diff --git a/demos/react-multi-client/src/devlink/_Builtin/Video.jsx b/demos/react-multi-client/src/devlink/_Builtin/Video.jsx new file mode 100644 index 00000000..7d75eaab --- /dev/null +++ b/demos/react-multi-client/src/devlink/_Builtin/Video.jsx @@ -0,0 +1,33 @@ +import React from "react"; +import { cj } from "../utils"; +const getAspectRatio = ({ width, height }) => + height && width ? height / width : 0; +export const Video = React.forwardRef(function Video( + { + className = "", + options = { height: 0, width: 0, title: "", url: "" }, + ...props + }, + ref +) { + const { height, title, url, width } = options; + return ( +
+