diff --git a/.github/actions/add-status/action.yaml b/.github/actions/add-status/action.yaml deleted file mode 100644 index b90e6986a95a..000000000000 --- a/.github/actions/add-status/action.yaml +++ /dev/null @@ -1,35 +0,0 @@ -name: Add status to commit -description: Add status to commit -inputs: - url: - description: "URL" - required: true - title: - description: "Title" - required: true - description: - description: "Description" - required: true - -runs: - using: "composite" - steps: - - name: Add URL to vercel deployment through *.prs.webstudio.is - uses: actions/github-script@v7 - with: - script: | - const branch = context.payload.pull_request?.head?.ref ?? context.payload.ref?.replace('refs/heads/', '') - const sha = context.payload.pull_request?.head?.sha ?? context.sha; - - const status = { - state: 'success', - target_url: '${{ inputs.url }}', - description: '${{ inputs.description }}', - context: '${{ inputs.title }}' - }; - - github.rest.repos.createCommitStatus({ - ...context.repo, - sha, - ...status - }); diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml deleted file mode 100644 index 5767b0b7eb2a..000000000000 --- a/.github/actions/ci-setup/action.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: CI setup - -description: | - Sets up the CI environment for the project. - -runs: - using: "composite" - - steps: - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - - run: pnpm install --frozen-lockfile --ignore-scripts - shell: bash diff --git a/.github/actions/vercel/action.yaml b/.github/actions/vercel/action.yaml deleted file mode 100644 index 20077162acd1..000000000000 --- a/.github/actions/vercel/action.yaml +++ /dev/null @@ -1,128 +0,0 @@ -name: "VERCEL BUILD AND DEPLOY" -description: "Builds and deploy vercel project" - -inputs: - vercel-token: - description: "Vercel token" - required: true - vercel-org-id: - description: "Vercel Organization ID" - required: true - vercel-project-id: - description: "Vercel Project ID" - required: true - ref-name: - description: "Branch" - required: true - sha: - description: "Sha" - required: true - environment: - description: "Sha" - required: true - -outputs: - domain: - description: "Domain" - value: ${{ steps.deploy.outputs.domain }} - inspect-url: - description: "Inspect URL" - value: ${{ steps.deploy.outputs.inspect-url }} - alias: - description: "Alias" - value: ${{ steps.alias.outputs.value }} - -runs: - using: "composite" - steps: - - id: branch - run: | - CLEAN_NAME="${REF_NAME/.staging/}" - CLEAN_NAME=$( echo "${CLEAN_NAME}" | sed 's/[^a-zA-Z0-9_-]//g' | tr A-Z a-z | tr _ - | sed 's/-\{2,\}/-/g' ) - echo "value=${CLEAN_NAME}" >> $GITHUB_OUTPUT - shell: bash - env: - REF_NAME: ${{ inputs.ref-name }} - - - id: short_sha - run: | - SHORT_SHA=$( echo "value=$(echo ${{ inputs.sha }} | cut -c1-7)" ) - echo "value=${SHORT_SHA}" >> $GITHUB_OUTPUT - shell: bash - - - name: CREATE VERCEL PROJECT FILE - run: | - mkdir -p .vercel - cat <<"EOF" > .vercel/project.json - { - "projectId": "${{ inputs.vercel-project-id }}", - "orgId": "${{ inputs.vercel-org-id }}", - "settings": { - "framework": "remix", - "devCommand": "pnpm dev", - "installCommand": "pnpm install", - "buildCommand": "pnpm --filter=@webstudio-is/http-client build && pnpm --filter=@webstudio-is/builder build", - "outputDirectory": null, - "rootDirectory": "apps/builder", - "directoryListing": false, - "nodeVersion": "20.x" - } - } - EOF - shell: bash - - - name: Build - run: | - export GITHUB_SHA=${{ inputs.sha }} - export GITHUB_REF_NAME=${{ inputs.ref-name }} - - pnpx vercel build - shell: bash - - - name: Patch - run: | - # When we deploy on Vercel, it generates a URL like webstudio-saas-mahqcavgo-getwebstudio.vercel.app. - # We use the alias oauth-wstd-00-staging.vercel.app for routing, which maps to oauth.staging.webstudio.is on the worker. - # Issue: Vercel proxies also set x-forwarded-host, which overrides our header. - # We are adding x-forwarded-ws-host on the worker as a workaround, but issues with request.url persist. - # Remix and the Vercel adapter lack support for header selection https://github.com/vercel/vercel/blob/9d4d4b6deb6294506016106f78e71f1984adcc7f/packages/remix/defaults/server-node.mjs#L44 - # Patching server-node.mjs directly without installing Vercel CLI was unsuccessful, so we are using string replacement instead. - - mapfile -t matching_files < <(grep -rl "req\.headers\['x-forwarded-host'\] || req\.headers\.host" "./apps/builder/build") - if [ ${#matching_files[@]} -eq 0 ]; then - echo "No files found containing the specified string." - exit 1 - fi - - echo "Files containing 'req.headers['x-forwarded-host'] || req.headers.host':" - printf '%s\n' "${matching_files[@]}" - - find ./apps/builder/build -type f -exec sed -i "s/req\.headers\['x-forwarded-host'\] || req\.headers\.host/req.headers['x-forwarded-ws-host'] || req.headers['x-forwarded-host'] || req.headers.host/g" {} + - shell: bash - - - name: Deploy - id: deploy - run: | - pnpx vercel deploy \ - --prebuilt \ - --token ${{ inputs.vercel-token }} \ - 2> >(tee info.txt >&2) | tee domain.txt - - echo "domain=$(cat ./domain.txt)" >> $GITHUB_OUTPUT - echo "inspect-url=$(cat info.txt | grep 'Inspect:' | awk '{print $2}')" >> $GITHUB_OUTPUT - - shell: bash - - - name: Set Alias - id: alias - run: | - ALIAS="${{ steps.branch.outputs.value }}" - - pnpx vercel alias set \ - "${{ steps.deploy.outputs.domain }}" \ - "${ALIAS}-wstd-00-${{ inputs.environment }}" \ - --token ${{ inputs.vercel-token }} \ - --scope getwebstudio - - echo "value=${ALIAS}" >> $GITHUB_OUTPUT - shell: bash diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 8470b6611e43..000000000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,27 +0,0 @@ -## Description - -1. What is this PR about (link the issue and add a short description) - -## Steps for reproduction - -1. click button -2. expect xyz - -## Code Review - -- [ ] hi @kof, I need you to do - - conceptual review (architecture, feature-correctness) - - detailed review (read every line) - - test it on preview - -## Before requesting a review - -- [ ] made a self-review -- [ ] added inline comments where things may be not obvious (the "why", not "what") - -## Before merging - -- [ ] tested locally and on preview environment (preview dev login: 0000) -- [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document -- [ ] added tests -- [ ] if any new env variables are added, added them to `.env` file diff --git a/.github/workflows/build-figma-tokens.yml b/.github/workflows/build-figma-tokens.yml deleted file mode 100644 index 08253e5f8878..000000000000 --- a/.github/workflows/build-figma-tokens.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Build and commit Figma tokens - -on: - push: - branches: - - figma-tokens - paths: - - packages/design-system/src/__generated__/figma-design-tokens.json - -jobs: - main: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - # We don't need a token to push from an action, - # but we need it if want the commit to trigger other workflows as normal - token: ${{ secrets.ACCESS_TOKEN_FOR_FIGMA_TOKENS }} - - - uses: ./.github/actions/ci-setup - - - name: Configure git - run: | - git config --global user.name 'Bot (build-figma-tokens.yml)' - git config --global user.email 'bot@localhost' - - - name: Switch branch - run: git checkout figma-tokens - - - name: Build tokens - run: pnpm build-figma-tokens - - - name: Commit and push - run: | - [[ -z `git status | grep figma-design-tokens.ts` ]] || git commit -m "Update figma-design-tokens.ts" packages/design-system/src/__generated__/figma-design-tokens.ts - git push diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml deleted file mode 100644 index 8f166944635f..000000000000 --- a/.github/workflows/chromatic.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Chromatic - -on: - push: - branches: - - main - pull_request: - -# cancel in-progress runs on new commits to same PR (gitub.event.number) -concurrency: - group: ${{ github.workflow }}-${{ github.event.number || github.sha }} - cancel-in-progress: true - -permissions: - contents: read # to fetch code (actions/checkout) - -jobs: - chromatic: - timeout-minutes: 20 - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 2 # we need to fetch at least parent commit to satisfy Chromatic - ref: ${{ github.event.pull_request.head.sha || github.sha }} # HEAD commit instead of merge commit - - - uses: ./.github/actions/ci-setup - - - name: Chromatic - id: chromatic - uses: chromaui/action@v11.3.0 - with: - projectToken: bea8dc1981d4 - buildScriptName: storybook:build diff --git a/.github/workflows/cli-r2-static.yaml b/.github/workflows/cli-r2-static.yaml deleted file mode 100644 index bbf61abfabbb..000000000000 --- a/.github/workflows/cli-r2-static.yaml +++ /dev/null @@ -1,111 +0,0 @@ -name: CLI R2 SSG - -on: - push: - branches: - - "*.staging" - -# cancel in-progress runs on new commits to same PR (gitub.event.number) -concurrency: - group: vercel-cli-r2-static-${{ github.workflow }}-${{ github.event.number || github.sha }} - cancel-in-progress: true - -permissions: - contents: read # to fetch code (actions/checkout) - statuses: write # This is required for the GitHub Script createCommitStatus to work - packages: write - -jobs: - build: - env: - COMPATIBILITY_DATE: 2024-04-10 - - environment: - name: "staging" - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.sha }} # HEAD commit instead of merge commit - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - - # TRY FIX cloudlare incident - - uses: unfor19/install-aws-cli-action@v1 - with: - version: "2.22.35" - verbose: false - arch: amd64 - - - name: pnpm instal - run: pnpm install --ignore-scripts - - - name: pnpm build - run: pnpm --filter 'ssg^...' run build - - # Ideally, execute 'pnpm deploy --prod', but @remix-run/dev doesn't support this flag. - # Despite being listed as a dependency, @remix-run/dev does not install the remix cli. - # TODO: Minimize artefact size due to frequent downloads on each publish. - - name: pnpm deploy - run: pnpm --filter 'ssg' deploy "${{ github.workspace }}/../ssg-template" - - - name: Make archive - run: | - tar --use-compress-program="zstd -19" -cf ssg-template.tar.zst ssg-template - working-directory: ${{ github.workspace }}/.. - - - name: Copy artifact - run: | - # For staging - aws s3 cp ssg-template.tar.zst "s3://${ARTEFACT_BUCKET_NAME}/public/ssg-template/${{ github.ref_name }}.tar.zst" - - # For production can be cached forever - aws s3 cp \ - --metadata-directive REPLACE --cache-control "public,max-age=31536102,immutable" \ - ssg-template.tar.zst "s3://${ARTEFACT_BUCKET_NAME}/public/ssg-template/${{ github.sha }}.tar.zst" - - working-directory: ${{ github.workspace }}/.. - env: - AWS_ENDPOINT_URL_S3: ${{ secrets.AWS_ENDPOINT_URL_S3 }} - AWS_REGION: ${{ secrets.AWS_REGION }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - ARTEFACT_BUCKET_NAME: ${{ secrets.ARTEFACT_BUCKET_NAME }} - - checks: - environment: - name: "staging" - - runs-on: ubuntu-latest - - needs: build - - steps: - - uses: pnpm/action-setup@v4 - with: - version: "9" - - - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Copy atrifact via http - run: curl -o ssg-template.tar.zst ${{ secrets.ARTEFACT_BUCKET_URL }}/public/ssg-template/${{ github.ref_name }}.tar.zst - - - name: Extract archive - run: tar --use-compress-program="zstd -d" -xf ssg-template.tar.zst -C . - - - name: Webstudio Build - run: pnpm webstudio build --template ssg --template internal - working-directory: ${{ github.workspace }}/ssg-template - - - name: Build - run: pnpm build - working-directory: ${{ github.workspace }}/ssg-template diff --git a/.github/workflows/cli-r2.yaml b/.github/workflows/cli-r2.yaml deleted file mode 100644 index 4b36b5087bc6..000000000000 --- a/.github/workflows/cli-r2.yaml +++ /dev/null @@ -1,131 +0,0 @@ -name: CLI R2 - -on: - push: - branches: - - "*.staging" - -# cancel in-progress runs on new commits to same PR (gitub.event.number) -concurrency: - group: vercel-cli-r2-${{ github.workflow }}-${{ github.event.number || github.sha }} - cancel-in-progress: true - -permissions: - contents: read # to fetch code (actions/checkout) - statuses: write # This is required for the GitHub Script createCommitStatus to work - packages: write - deployments: write - -jobs: - build: - env: - COMPATIBILITY_DATE: 2024-04-10 - - environment: - name: "staging" - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.sha }} # HEAD commit instead of merge commit - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - - # TRY FIX cloudlare incident - - uses: unfor19/install-aws-cli-action@v1 - with: - version: "2.22.35" - verbose: false - arch: amd64 - - - name: pnpm instal - run: pnpm install --ignore-scripts - - - name: pnpm build - run: pnpm --filter 'webstudio-cloudflare-template^...' run build - - # Ideally, execute 'pnpm deploy --prod', but @remix-run/dev doesn't support this flag. - # Despite being listed as a dependency, @remix-run/dev does not install the remix cli. - # TODO: Minimize artefact size due to frequent downloads on each publish. - - name: pnpm deploy - run: pnpm --filter 'webstudio-cloudflare-template' deploy "${{ github.workspace }}/../cloudflare-template" - - - name: Make archive - run: | - tar --use-compress-program="zstd -19" -cf cloudflare-template.tar.zst cloudflare-template - working-directory: ${{ github.workspace }}/.. - - - name: Copy artifact - run: | - # For staging - aws s3 cp cloudflare-template.tar.zst "s3://${ARTEFACT_BUCKET_NAME}/public/cloudflare-template/${{ github.ref_name }}.tar.zst" - - # For production can be cached forever - aws s3 cp \ - --metadata-directive REPLACE --cache-control "public,max-age=31536102,immutable" \ - cloudflare-template.tar.zst "s3://${ARTEFACT_BUCKET_NAME}/public/cloudflare-template/${{ github.sha }}.tar.zst" - - working-directory: ${{ github.workspace }}/.. - env: - AWS_ENDPOINT_URL_S3: ${{ secrets.AWS_ENDPOINT_URL_S3 }} - AWS_REGION: ${{ secrets.AWS_REGION }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - ARTEFACT_BUCKET_NAME: ${{ secrets.ARTEFACT_BUCKET_NAME }} - - checks: - environment: - name: "staging" - - runs-on: ubuntu-latest - - needs: build - - steps: - - uses: pnpm/action-setup@v4 - with: - version: "9" - - - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Copy atrifact via http - run: curl -o cloudflare-template.tar.zst ${{ secrets.ARTEFACT_BUCKET_URL }}/public/cloudflare-template/${{ github.ref_name }}.tar.zst - - - name: Extract archive - run: tar --use-compress-program="zstd -d" -xf cloudflare-template.tar.zst -C . - - - name: Webstudio Build - run: pnpm webstudio build --template internal --template saas-helpers --template cloudflare --assets false - working-directory: ${{ github.workspace }}/cloudflare-template - - - name: Remix Build - run: pnpm build - working-directory: ${{ github.workspace }}/cloudflare-template - - - name: WRANGLER Build - run: | - NODE_ENV=production pnpm wrangler deploy \ - --name build \ - --compatibility-date '${COMPATIBILITY_DATE}' \ - --minify true \ - --logpush true \ - --dry-run \ - --outdir dist \ - './functions/[[path]].ts' - - working-directory: ${{ github.workspace }}/cloudflare-template - - delete-github-deployments: - needs: checks - uses: ./.github/workflows/delete-github-deployments.yml - with: - ref: ${{ github.ref_name }} diff --git a/.github/workflows/delete-github-deployments.yml b/.github/workflows/delete-github-deployments.yml deleted file mode 100644 index 26860f2613c3..000000000000 --- a/.github/workflows/delete-github-deployments.yml +++ /dev/null @@ -1,52 +0,0 @@ -# https://github.com/orgs/community/discussions/36919 -name: Delete github deployments - -on: - workflow_call: - inputs: - ref: - type: string - required: true - -permissions: - deployments: write - -jobs: - delete_github_deployments: - runs-on: ubuntu-latest - if: ${{ always() }} - steps: - - name: Delete Previous deployments - uses: actions/github-script@v7 - env: - REF: ${{ inputs.ref }} - with: - script: | - const { REF } = process.env; - - console.log(REF); - - const deployments = await github.rest.repos.listDeployments({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: REF, - per_page: 100 - }); - - console.log(deployments); - - await Promise.allSettled( - deployments.data.map(async (deployment) => { - await github.rest.repos.createDeploymentStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - deployment_id: deployment.id, - state: 'inactive' - }); - return github.rest.repos.deleteDeployment({ - owner: context.repo.owner, - repo: context.repo.repo, - deployment_id: deployment.id - }); - }) - ); diff --git a/.github/workflows/fixtures-test.yml b/.github/workflows/fixtures-test.yml deleted file mode 100644 index 5f37750af516..000000000000 --- a/.github/workflows/fixtures-test.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Fixtures tests - -on: - workflow_call: - inputs: - builder-url: - required: true - type: string - builder-host: - required: true - type: string - -permissions: - contents: read # to fetch code (actions/checkout) - -jobs: - checks: - timeout-minutes: 20 - - strategy: - matrix: - os: [ubuntu-latest, windows-latest] - - runs-on: ${{ matrix.os }} - - env: - DATABASE_URL: postgres:// - AUTH_SECRET: test - BUILDER_URL_DEPRECATED: ${{ inputs.builder-url }} - BUILDER_HOST: ${{ inputs.builder-host }} - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} # HEAD commit instead of merge commit - - - uses: ./.github/actions/ci-setup - - # Testing fixtures for vercel template - - name: Test cli --help flag - working-directory: ./fixtures/webstudio-remix-vercel - run: pnpm cli --help - - - name: Testing cli link command - run: pnpm --filter='./fixtures/*' --sequential run fixtures:link - - - name: Testing cli sync command - run: pnpm --filter='./fixtures/*' run --parallel fixtures:sync - - - name: Testing cli build command - run: pnpm --filter='./fixtures/*' run --parallel fixtures:build - - - name: Prepare for diffing - shell: bash - run: | - find . -type f -path "./fixtures/*/.webstudio/data.json" -exec sed -i 's|"origin": ".*"|"origin": "https://main.development.webstudio.is"|g' {} + - - - name: Test git diff - # This command will fail if there are uncommitted changes, i.e something has broken - run: git diff --name-only HEAD --exit-code - - - name: Show changed files and diff - if: ${{ failure() }} - run: | - echo "Changed files are:" - git diff --name-only HEAD - git diff HEAD | head -n 1000 diff --git a/.github/workflows/lint-pull-request.yaml b/.github/workflows/lint-pull-request.yaml deleted file mode 100644 index 96a85a862bf9..000000000000 --- a/.github/workflows/lint-pull-request.yaml +++ /dev/null @@ -1,85 +0,0 @@ -name: "Lint PR" - -on: - pull_request: - types: - - opened - - edited - - synchronize - - pull_request_target: - types: - - opened - - edited - - synchronize - -permissions: - pull-requests: write - -jobs: - main: - name: Validate PR title - runs-on: ubuntu-latest - steps: - - uses: amannn/action-semantic-pull-request@v5 - id: lint_pr_title - with: - # Configure which types are allowed (newline-delimited). - # Default: https://github.com/commitizen/conventional-commit-types - types: | - feat - fix - docs - style - refactor - perf - test - build - ci - chore - revert - experimental - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - if: always() && (steps.lint_pr_title.outputs.error_message != null) - uses: marocchino/sticky-pull-request-comment@v2 - # When the previous steps fails, the workflow would stop. By adding this - # condition you can continue the execution with the populated error message. - - with: - header: pr-title-lint-error - message: | - Hey there and thank you for opening this pull request! 👋🏼 - - We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted. - - Details: - - ``` - ${{ steps.lint_pr_title.outputs.error_message }} - ``` -
- Release types - - - **feat** - A new feature - - **fix** - A bug fix - - **docs** - Documentation only changes - - **style** - Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) - - **refactor** - A code change that neither fixes a bug nor adds a feature - - **perf** - A code change that improves performance - - **test** - Adding missing tests or correcting existing tests - - **build** - Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) - - **ci** - Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs) - - **chore** - Other changes that don't modify src or test files - - **revert** - Reverts a previous commit - - **experimental** - Flagged feature - -
- - # Delete a previous comment when the issue has been resolved - - if: ${{ steps.lint_pr_title.outputs.error_message == null }} - uses: marocchino/sticky-pull-request-comment@v2 - with: - header: pr-title-lint-error - delete: true diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 3fec2ba4d15f..000000000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,101 +0,0 @@ -name: Main workflow - -on: - push: - branches: - - main - pull_request: - -# cancel in-progress runs on new commits to same PR (gitub.event.number) -concurrency: - group: ${{ github.workflow }}-${{ github.event.number || github.sha }} - cancel-in-progress: true - -permissions: - contents: read # to fetch code (actions/checkout) - -jobs: - checks: - timeout-minutes: 20 - - env: - DATABASE_URL: postgres:// - AUTH_SECRET: test - - runs-on: ubuntu-24.04-arm - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} # HEAD commit instead of merge commit - - - uses: ./.github/actions/ci-setup - - - uses: actions/cache@v4 - with: - path: | - ./node_modules/.cache/prettier/.prettier-cache - ./node_modules/.cache/eslint/.eslint-cache - key: checks-${{ github.sha }} - restore-keys: checks- - - - run: echo ===SHA USED=== ${{ github.event.pull_request.head.sha || github.sha }} # todo: remove after check whats happening on main - - - run: pnpm prettier --cache --check "**/*.{js,md,ts,tsx}" - - - run: pnpm lint --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslint-cache - - - run: pnpm -r test - - run: pnpm --filter=@webstudio-is/prisma-client build - - run: pnpm -r typecheck - - check-size: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} # HEAD commit instead of merge commit - - - uses: ./.github/actions/ci-setup - - - run: pnpm --filter "{./fixtures/*}..." build - - - uses: actions/github-script@v7 - with: - script: | - const assertSize = async (directory, maxSize) => { - let result = '' - await exec.exec('du', ['-sk', directory], { - silent: true, - listeners: { - stdout: (data) => { - result += data.toString() - } - } - }) - const size = Number.parseInt(result, 10) - return { - passed: size <= maxSize, - size, - diff: size - maxSize, - directory, - } - } - const results = [ - await assertSize('./fixtures/ssg/dist/client', 352), - await assertSize('./fixtures/webstudio-remix-netlify-functions/build/client', 440), - await assertSize('./fixtures/webstudio-remix-vercel/build/client', 926), - ] - for (const result of results) { - if (result.passed) { - console.info(`${result.directory}: ${result.size}kB (${result.diff}kB)`) - } else { - console.info('') - console.error(`${result.directory}: ${result.size}kB (+${result.diff}kB)`) - } - } - if (results.some(result => result.passed === false)) { - console.error('Some fixtures exceeded limits') - process.exit(1) - } diff --git a/.github/workflows/publish-beta.yml b/.github/workflows/publish-beta.yml deleted file mode 100644 index abe907266195..000000000000 --- a/.github/workflows/publish-beta.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Publish beta packages on NPM 📦 - -on: - pull_request: - types: - - labeled - -jobs: - publish: - # prevents this action from running on forks - if: | - github.repository_owner == 'webstudio-is' && - startsWith(github.event.label.name, 'publish:') - - timeout-minutes: 20 - - runs-on: ubuntu-latest - - env: - DATABASE_URL: postgres:// - AUTH_SECRET: test - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} # HEAD commit instead of merge commit - - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - - - name: Creating .npmrc - run: | - cat << EOF > "$HOME/.npmrc" - //registry.npmjs.org/:_authToken=$NPM_TOKEN - EOF - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - # compute short sha - - id: short_sha - run: echo "value=$(echo ${{ github.event.pull_request.head.sha || github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT - - - id: tag - run: echo "value=$(echo ${{ github.event.label.name }} | cut -d ':' -f2)" >> $GITHUB_OUTPUT - - - name: bump version to 0.0.0-${{ steps.short_sha.outputs.value }} - run: | - pnpx replace-in-files-cli \ - --string="0.0.0-webstudio-version" \ - --replacement="0.0.0-${{ steps.short_sha.outputs.value }}" \ - "**/package.json" - - - run: pnpm install --ignore-scripts - - run: pnpm --filter="webstudio..." build - - run: pnpm --filter="webstudio..." dts - - - name: Publishing ${{ steps.tag.outputs.value }} tag with sha ${{ steps.short_sha.outputs.value }} - run: pnpm -r publish --tag "${{ steps.tag.outputs.value }}" --no-git-checks --access public diff --git a/.github/workflows/re-create-figma-tokens-branch.yml b/.github/workflows/re-create-figma-tokens-branch.yml deleted file mode 100644 index 95d409ad50d8..000000000000 --- a/.github/workflows/re-create-figma-tokens-branch.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Re-create branch for Figma tokens - -on: delete - -permissions: - contents: write - -jobs: - main: - runs-on: ubuntu-latest - - # run if figma-tokens was deleted - if: ${{ github.event.ref == 'figma-tokens'}} - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Re-create branch - run: | - git checkout main - git checkout -b figma-tokens - git push --set-upstream origin figma-tokens diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 1a19e3e25d31..000000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: Release - -on: - push: - tags: - - '[0-9]+.[0-9]+.[0-9]+' - -jobs: - release: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/github-script@v7 - with: - script: | - const latestRelease = await github.rest.repos.getLatestRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - }) - const commits = await github.rest.repos.compareCommitsWithBasehead({ - owner: context.repo.owner, - repo: context.repo.repo, - basehead: `refs/tags/${latestRelease.data.tag_name}...${context.ref}`, - }) - - const groups = { - feat: [`## Features\n`], - fix: [`## Fixes\n`], - docs: [`## Documentation\n`], - experimental: [`## Experimental\n`], - other: [`## Other changes\n`], - } - for (const commit of commits.data.commits) { - const match = commit.commit.message.match(/^(?\w+)\s*:\s*(?.+)\n*/) - const type = match?.groups?.type - const message = match?.groups?.message - if (type && message) { - const availableType = type in groups ? type : 'other' - const capitalized = message[0].toLocaleUpperCase() + message.slice(1) - groups[availableType].push(`- ${capitalized} by @${commit.author.login}`) - } - } - - const tag_name = context.ref.slice('refs/tags/'.length) - const fullChangelog = `**Full Changelog**: https://github.com/${context.repo.owner}/${context.repo.repo}/compare/${latestRelease.data.tag_name}...${tag_name}` - const changelog = Object.values(groups) - .filter(lines => lines.length > 1) - .map(lines => lines.join('\n')) - .concat(fullChangelog) - .join('\n\n') - console.info(changelog) - - await github.rest.repos.createRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - tag_name, - name: tag_name, - body: changelog, - }) - - publish: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} # tag name - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - - - id: version - run: echo "value=$(echo ${{ github.ref }} | sed 's/refs\/tags\///')" >> $GITHUB_OUTPUT - - name: bump version to ${{ steps.version.outputs.value }} - run: | - pnpx replace-in-files-cli \ - --string="0.0.0-webstudio-version" \ - --replacement="${{ steps.version.outputs.value }}" \ - "**/package.json" - - - name: pnpm instal - run: pnpm install --ignore-scripts - - run: pnpm --filter="webstudio..." build - - run: pnpm --filter="webstudio..." dts - - - name: Creating .npmrc - run: | - cat << EOF > "$HOME/.npmrc" - //registry.npmjs.org/:_authToken=$NPM_TOKEN - EOF - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - run: pnpm -r publish --access public --no-git-checks diff --git a/.github/workflows/vercel-deploy-staging.yml b/.github/workflows/vercel-deploy-staging.yml deleted file mode 100644 index ba79625ac6ec..000000000000 --- a/.github/workflows/vercel-deploy-staging.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Vercel Deploy Staging - -on: - push: - -# cancel in-progress runs on new commits to same PR (gitub.event.number) -concurrency: - group: vercel-deploy-${{ github.workflow }}-${{ github.event.number || github.sha }} - cancel-in-progress: true - -permissions: - contents: read # to fetch code (actions/checkout) - statuses: write # This is required for the GitHub Script createCommitStatus to work - deployments: write - -jobs: - deployment: - # Execute development and staging on staging branches - # Execute only development on all other branches - strategy: - matrix: - environment: - - staging - - development - is-staging: - - ${{ endsWith(github.ref_name, '.staging') }} - exclude: - - environment: staging - is-staging: false - - environment: - name: ${{ matrix.environment }} - - timeout-minutes: 20 - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} # HEAD commit instead of merge commit - - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - - - uses: ./.github/actions/vercel - id: vercel - name: Deploy to Vercel - with: - vercel-token: ${{ secrets.VERCEL_TOKEN }} - vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} - vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} - ref-name: ${{ github.ref_name }} - sha: ${{ github.sha }} - environment: ${{ matrix.environment }} - - - name: Debug Vercel Outputs - run: | - echo "domain=${{ steps.vercel.outputs.domain }}" - echo "inspect-url=${{ steps.vercel.outputs.inspect-url }}" - echo "alias=${{ steps.vercel.outputs.alias }}" - - - uses: ./.github/actions/add-status - with: - title: "⏰ [${{ matrix.environment }}] Vercel Inspection" - description: "[${{ matrix.environment }}] Vercel logs" - url: "${{ steps.vercel.outputs.inspect-url }}" - - - uses: ./.github/actions/add-status - with: - title: "⭐ [${{ matrix.environment }}] Apps Webstudio URL" - description: "[${{ matrix.environment }}] Site url" - url: "https://${{ steps.vercel.outputs.alias }}.${{ matrix.environment }}.webstudio.is" - - outputs: - builder-url: "https://${{ steps.vercel.outputs.alias }}.${{ matrix.environment }}.webstudio.is" - builder-host: "${{ steps.vercel.outputs.alias }}.${{ matrix.environment }}.webstudio.is" - - fixtures-test: - needs: deployment - uses: ./.github/workflows/fixtures-test.yml - with: - builder-url: ${{ needs.deployment.outputs.builder-url }} - builder-host: ${{ needs.deployment.outputs.builder-host }} - - delete-github-deployments: - needs: fixtures-test - uses: ./.github/workflows/delete-github-deployments.yml - with: - ref: ${{ github.ref_name }} diff --git a/.github/workflows/vis-reg-tests.yml b/.github/workflows/vis-reg-tests.yml deleted file mode 100644 index 8f86a93431b9..000000000000 --- a/.github/workflows/vis-reg-tests.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Visual Regression Tests - -on: - push: - branches: - - main - pull_request: - -# cancel in-progress runs on new commits to same PR (gitub.event.number) -concurrency: - group: ${{ github.workflow }}-${{ github.event.number || github.sha }} - cancel-in-progress: true - -permissions: - contents: read # to fetch code (actions/checkout) - -jobs: - lost-pixel: - timeout-minutes: 20 - runs-on: ubuntu-latest - env: - DATABASE_URL: postgres:// - AUTH_SECRET: test - steps: - - uses: actions/checkout@v4 - #with: - #ref: ${{ github.event.pull_request.head.sha || github.sha }} # HEAD commit instead of merge commit - - - uses: ./.github/actions/ci-setup - - - run: VISUAL_TESTING=true pnpm storybook:build - - - name: Lost Pixel - uses: lost-pixel/lost-pixel@v3.16.0 - env: - LOST_PIXEL_API_KEY: 8b76db6c-b9f0-46d1-982f-70900a02690a diff --git a/apps/builder/.env b/apps/builder/.env index 9f6daf28c5f9..30dfcdaec4cd 100644 --- a/apps/builder/.env +++ b/apps/builder/.env @@ -4,8 +4,6 @@ DATABASE_URL=postgresql://postgres:pass@localhost/webstudio?pgbouncer=true DIRECT_URL=postgresql://postgres:pass@localhost/webstudio AUTH_SECRET="# Linux: $(openssl rand -hex 32) or go to https://generate-secret.now.sh/64" -# GH_CLIENT_SECRET= -# GH_CLIENT_ID= DEV_LOGIN=true # Restrictions diff --git a/apps/builder/app/auth/login.tsx b/apps/builder/app/auth/login.tsx index f6b64839ef18..a56579bb46f8 100644 --- a/apps/builder/app/auth/login.tsx +++ b/apps/builder/app/auth/login.tsx @@ -7,7 +7,7 @@ import { Text, theme, } from "@webstudio-is/design-system"; -import { GithubIcon, GoogleIcon, WebstudioIcon } from "@webstudio-is/icons"; +import { GoogleIcon, WebstudioIcon } from "@webstudio-is/icons"; import { Form } from "@remix-run/react"; import { authPath } from "~/shared/router-utils"; import { SecretLogin } from "./secret-login"; @@ -21,14 +21,12 @@ const globalStyles = globalCss({ export type LoginProps = { errorMessage?: string; - isGithubEnabled?: boolean; isGoogleEnabled?: boolean; isSecretLoginEnabled?: boolean; }; export const Login = ({ errorMessage, - isGithubEnabled, isGoogleEnabled, isSecretLoginEnabled, }: LoginProps) => { @@ -73,18 +71,6 @@ export const Login = ({ > Sign in with Google - {isSecretLoginEnabled && } diff --git a/apps/builder/app/builder/builder.tsx b/apps/builder/app/builder/builder.tsx index 6041f456dba8..9e16e3bac8f5 100644 --- a/apps/builder/app/builder/builder.tsx +++ b/apps/builder/app/builder/builder.tsx @@ -41,7 +41,6 @@ import { BlockingAlerts } from "./features/blocking-alerts"; import { useSyncPageUrl } from "~/shared/pages"; import { useMount, useUnmount } from "~/shared/hook-utils/use-mount"; import { subscribeCommands } from "~/builder/shared/commands"; -import { AiCommandBar } from "./features/ai/ai-command-bar"; import { ProjectSettings } from "./features/project-settings"; import type { UserPlanFeatures } from "~/shared/db/user-plan-features.server"; import { @@ -437,7 +436,6 @@ export const Builder = ({ }} /> - {isDesignMode && } diff --git a/apps/builder/app/builder/features/ai/ai-button.tsx b/apps/builder/app/builder/features/ai/ai-button.tsx deleted file mode 100644 index 1f48911d489b..000000000000 --- a/apps/builder/app/builder/features/ai/ai-button.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/** - * AI Button has own style override https://www.figma.com/file/xCBegXEWxROLqA1Y31z2Xo/%F0%9F%93%96-Webstudio-Design-Docs?node-id=7579%3A43452&mode=dev - * what make it different from Button component styling - */ - -import { CommandBarButton, styled, theme } from "@webstudio-is/design-system"; -import { forwardRef, type ComponentProps } from "react"; - -type CommandButtonProps = ComponentProps; - -// @todo: Move to design system, if no additional changes are needed -const AiCommandBarButtonStyled = styled(CommandBarButton, { - "&[data-state=disabled]": { - "&[data-state=disabled]": { - background: "#1D1D1D", - color: "#646464", - outline: "1px solid #646464", - }, - "&[data-pending=true]": { - background: "#1D1D1D", - color: theme.colors.foregroundContrastMain, - outline: "none", - }, - }, -}); - -export const AiCommandBarButton = forwardRef< - HTMLButtonElement, - CommandButtonProps ->((props, ref) => { - return ; -}); - -AiCommandBarButton.displayName = "AiCommandBarButton"; diff --git a/apps/builder/app/builder/features/ai/ai-command-bar.tsx b/apps/builder/app/builder/features/ai/ai-command-bar.tsx deleted file mode 100644 index 49375b711e8b..000000000000 --- a/apps/builder/app/builder/features/ai/ai-command-bar.tsx +++ /dev/null @@ -1,497 +0,0 @@ -import { - AutogrowTextArea, - Box, - Button, - CommandBar, - CommandBarButton, - CommandBarContentPrompt, - CommandBarContentSection, - CommandBarContentSeparator, - CommandBarTrigger, - Grid, - ScrollArea, - Text, - Tooltip, - theme, - toast, - useDisableCanvasPointerEvents, -} from "@webstudio-is/design-system"; -import { - AiIcon, - MicIcon, - ChevronUpIcon, - ExternalLinkIcon, - StopIcon, - LargeXIcon, - AiLoadingIcon, -} from "@webstudio-is/icons"; -import { useRef, useState, type ComponentPropsWithoutRef } from "react"; -import { - $collaborativeInstanceSelector, - $selectedInstanceSelector, -} from "~/shared/nano-states"; -import { useMediaRecorder } from "./hooks/media-recorder"; -import { useLongPressToggle } from "./hooks/long-press-toggle"; -import { AiCommandBarButton } from "./ai-button"; -import { fetchTranscription } from "./ai-fetch-transcription"; -import { fetchResult } from "./ai-fetch-result"; -import { useEffectEvent } from "~/shared/hook-utils/effect-event"; -import { AiApiException, RateLimitException } from "./api-exceptions"; -import { getSetting, setSetting } from "~/builder/shared/client-settings"; -import { flushSync } from "react-dom"; -import { $selectedPage } from "~/shared/awareness"; -import { RelativeTime } from "~/builder/shared/relative-time"; - -type PartialButtonProps> = { - [key in keyof T]?: T[key]; -}; - -const useSelectText = () => { - const ref = useRef(null); - const selectText = () => { - ref.current?.focus(); - ref.current?.select(); - }; - return [ref, selectText] as const; -}; - -const initialPrompts = [ - "Create a hero section with a heading, subheading in white, CTA button and an image of a mountain in the background, add light blur to the image", - "Create a two column feature section with a heading and subheading in the left column, and an image that covers the right column", - "Create a testimonials section on 2 rows. The first row has a heading and subheading, the second row has 3 testimonial cards with an image, headline, description and link.", -]; - -export const AiCommandBar = () => { - const [value, setValue] = useState(""); - const [prompts, setPrompts] = useState(initialPrompts); - const isMenuOpen = getSetting("isAiMenuOpen"); - const setIsMenuOpen = (value: boolean) => { - setSetting("isAiMenuOpen", value); - }; - - const [isAudioTranscribing, setIsAudioTranscribing] = useState(false); - const [isAiRequesting, setIsAiRequesting] = useState(false); - const abortController = useRef(undefined); - const recordButtonRef = useRef(null); - const guardIdRef = useRef(0); - const { enableCanvasPointerEvents, disableCanvasPointerEvents } = - useDisableCanvasPointerEvents(); - - const getValue = useEffectEvent(() => { - return value; - }); - const [textAreaRef, selectPrompt] = useSelectText(); - - const { - start, - stop, - cancel, - state: mediaRecorderState, - } = useMediaRecorder({ - onError: (error) => { - if (error instanceof DOMException && error.name === "NotAllowedError") { - toast.info("Please enable your microphone."); - return; - } - if (error instanceof Error) { - toast.error(error.message); - return; - } - toast.error(`Unknown Error: ${error}`); - }, - onComplete: async (file) => { - try { - setIsAudioTranscribing(true); - guardIdRef.current++; - const guardId = guardIdRef.current; - const text = await fetchTranscription(file); - if (guardId !== guardIdRef.current) { - return; - } - - const currentValue = getValue(); - const newValue = [currentValue, text].filter(Boolean).join(" "); - - setValue(newValue); - handleAiRequest(newValue); - } catch (error) { - if (error instanceof RateLimitException) { - toast.info( - <> - Temporary AI rate limit reached. Please wait{" "} - and try again. - - ); - return; - } - - // Above are known errors; we're not interested in logging them. - console.error(error); - - if (error instanceof AiApiException) { - toast.error(`API Internal Error: ${error.message}`); - return; - } - - if (error instanceof Error) { - // Unknown error, show toast - toast.error(`Unknown Error: ${error.message}`); - } - } finally { - setIsAudioTranscribing(false); - } - }, - onReportSoundAmplitude: (amplitude) => { - recordButtonRef.current?.style.setProperty( - "--ws-ai-command-bar-amplitude", - amplitude.toString() - ); - }, - }); - - const longPressToggleProps = useLongPressToggle({ - onStart: () => { - start(); - disableCanvasPointerEvents(); - }, - onEnd: () => { - stop(); - enableCanvasPointerEvents(); - }, - onCancel: () => { - cancel(); - enableCanvasPointerEvents(); - }, - }); - - const handleAiRequest = async (prompt: string) => { - if (abortController.current) { - if (abortController.current.signal.aborted === false) { - console.warn(`For some reason previous operation is not aborted.`); - } - - abortController.current.abort(); - } - - const localAbortController = new AbortController(); - abortController.current = localAbortController; - - setIsAiRequesting(true); - - // Skip Abort Logic for now - try { - const page = $selectedPage.get(); - const rootInstanceSelector = page?.rootInstanceId - ? [page.rootInstanceId] - : []; - const instanceSelector = - $selectedInstanceSelector.get() ?? rootInstanceSelector; - - const [instanceId] = instanceSelector; - - if (instanceId === undefined) { - // Must not happen, we always have root instance - throw new Error("No element selected"); - } - - $collaborativeInstanceSelector.set(instanceSelector); - await fetchResult(prompt, instanceId, abortController.current.signal); - - if (localAbortController !== abortController.current) { - // skip - return; - } - - setPrompts((previousPrompts) => [prompt, ...previousPrompts]); - - setValue(""); - } catch (error) { - if ( - (error instanceof Error && error.message.startsWith("AbortError:")) || - (error instanceof DOMException && error.name === "AbortError") - ) { - // Aborted by user request - return; - } - - if (error instanceof RateLimitException) { - toast.info( - <> - Temporary AI rate limit reached. Please wait{" "} - and try again. - - ); - return; - } - - // Above is known errors, we are not interesting in - console.error(error); - - if (error instanceof AiApiException) { - toast.error(`API Internal Error: ${error.message}`); - return; - } - - if (error instanceof Error) { - // Unknown error, show toast - toast.error(`Unknown Error: ${error.message}`); - } - } finally { - abortController.current = undefined; - $collaborativeInstanceSelector.set(undefined); - setIsAiRequesting(false); - } - }; - - const handleAiButtonClick = () => { - if (value.trim().length === 0) { - return; - } - handleAiRequest(value); - }; - - const handlePropmptClick = (prompt: string) => { - if (textAreaRef.current?.disabled) { - return; - } - // We can't select text right away because value will be set using setState. - flushSync(() => { - setValue(prompt); - }); - selectPrompt(); - }; - - if (getSetting("isAiCommandBarVisible") === false) { - return; - } - - let textAreaPlaceholder = "Welcome to Webstudio AI alpha!"; - let textAreaValue = value; - let textAreaDisabled = false; - - let recordButtonTooltipContent = undefined; - let recordButtonColor: ComponentPropsWithoutRef["color"] = - "dark-ghost"; - let recordButtonProps: PartialButtonProps = longPressToggleProps; - let recordButtonIcon = ; - let recordButtonDisabled = false; - - let aiButtonDisabled = false; - let aiButtonTooltip: string | undefined = - value.length === 0 ? undefined : "Generate AI results"; - let aiButtonPending = false; - let aiIcon = ; - - if (isAudioTranscribing) { - textAreaPlaceholder = "Transcribing voice..."; - // Show placeholder instead - textAreaValue = ""; - textAreaDisabled = true; - - recordButtonDisabled = true; - - aiButtonTooltip = undefined; - aiButtonDisabled = true; - } - - if (mediaRecorderState === "recording") { - textAreaPlaceholder = "Recording voice..."; - // Show placeholder instead - textAreaValue = ""; - textAreaDisabled = true; - aiButtonDisabled = true; - - recordButtonColor = "destructive"; - recordButtonIcon = ; - - aiButtonTooltip = undefined; - } - - if (isAiRequesting) { - textAreaDisabled = true; - - recordButtonTooltipContent = "Cancel"; - recordButtonProps = { - onClick: () => { - // Cancel AI request - abortController.current?.abort(); - }, - }; - recordButtonIcon = ; - - aiButtonTooltip = "Generating ..."; - aiButtonDisabled = true; - aiButtonPending = true; - aiIcon = ; - } - - return ( - - - } - > - - - - - - - - - { - if (event.key === "Enter" && event.shiftKey === false) { - event.preventDefault(); - handleAiRequest(value); - } - }} - /> - - - - - - {recordButtonIcon} - - - - - - {aiIcon} - - - - - ); -}; - -const CommandBarContent = (props: { - prompts: string[]; - onPromptClick: (value: string) => void; -}) => { - // @todo enable when we will have shortcut - // const shortcutText = "⌘⇧Q"; - - return ( - <> - - - Welcome to Webstudio AI alpha! - - - - - - - - {props.prompts.length > 0 && ( - <> - - - - {/* negative then positive margin is used to preserve focus outline on command prompts */} - - - {props.prompts.map((prompt, index) => ( - - props.onPromptClick(prompt)} - > - {prompt} - - - ))} - - - - - )} - - ); -}; diff --git a/apps/builder/app/builder/features/ai/ai-fetch-result.ts b/apps/builder/app/builder/features/ai/ai-fetch-result.ts deleted file mode 100644 index 1ca306f8d86f..000000000000 --- a/apps/builder/app/builder/features/ai/ai-fetch-result.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { z } from "zod"; -import { - copywriter, - operations, - handleAiRequest, - commandDetect, -} from "@webstudio-is/ai"; -import { createRegularStyleSheet } from "@webstudio-is/css-engine"; -import { - generateJsxElement, - generateJsxChildren, - getIndexesWithinAncestors, - idAttribute, - componentAttribute, -} from "@webstudio-is/react-sdk"; -import { - type WsEmbedTemplate, - Instance, - createScope, - findTreeInstanceIds, -} from "@webstudio-is/sdk"; -import { computed } from "nanostores"; -import { - $dataSources, - $instances, - $project, - $props, - $registeredComponentMetas, - $styleSourceSelections, - $styles, -} from "~/shared/nano-states"; -import { applyOperations, patchTextInstance } from "./apply-operations"; -import { restAi } from "~/shared/router-utils"; -import untruncateJson from "untruncate-json"; -import { RequestParams } from "~/routes/rest.ai._index"; -import { - AiApiException, - RateLimitException, - textToRateLimitMeta, -} from "./api-exceptions"; -import { isFeatureEnabled } from "@webstudio-is/feature-flags"; -import { fetch } from "~/shared/fetch.client"; -import { $selectedInstance } from "~/shared/awareness"; - -const unknownArray = z.array(z.unknown()); - -const onResponseReceived = async (response: Response) => { - if (response.ok === false) { - const text = await response.text(); - - if (response.status === 429) { - const meta = textToRateLimitMeta(text); - throw new RateLimitException(text, meta); - } - - throw new Error( - `Fetch error status="${response.status}" text="${text.slice(0, 1000)}"` - ); - } -}; - -export const fetchResult = async ( - prompt: string, - instanceId: Instance["id"], - abortSignal: AbortSignal -): Promise => { - const commandsResponse = await handleAiRequest( - fetch(restAi("detect"), { - method: "POST", - body: JSON.stringify({ prompt }), - signal: abortSignal, - }), - { - onResponseReceived, - } - ); - - if (commandsResponse.type === "stream") { - throw new Error( - "Commands detection is not using streaming. Something went wrong." - ); - } - - if (commandsResponse.success === false) { - // Server error response - throw new Error(commandsResponse.data.message); - } - - const project = $project.get(); - - const availableComponentsNames = $availableComponentsNames.get(); - const [styles, jsx] = $jsx.get() || ["", ""]; - - const requestParams = { - jsx: `${styles}${jsx}`, - components: availableComponentsNames, - projectId: project?.id, - instanceId, - prompt, - }; - - // @todo Future commands might not require all the requestParams above. - // When that will be the case, we should revisit the validatin below. - if (requestParams.instanceId === undefined) { - throw new Error("Please select an instance on the canvas."); - } - - // @todo can be covered by ts - if ( - RequestParams.omit({ command: true }).safeParse(requestParams).success === - false - ) { - throw new Error("Invalid prompt data"); - } - - const appliedOperations = new Set(); - - const promises = await Promise.allSettled( - commandsResponse.data.map((command) => - handleAiRequest( - fetch(restAi(), { - method: "POST", - body: JSON.stringify({ - ...requestParams, - command, - jsx: - // Delete instances don't need CSS. - command === operations.deleteInstanceName - ? jsx - : requestParams.jsx, - // @todo This helps the operations chain disambiguating operation detection. - // Ideally though the operations chain can be executed just for one - // specific kind of operation i.e. `command`. - prompt: `${command}:\n\n${requestParams.prompt}`, - }), - signal: abortSignal, - }), - { - onResponseReceived, - onChunk: (operationId, { completion }) => { - if (operationId === "copywriter") { - try { - const unparsedDataArray = unknownArray.parse( - JSON.parse(untruncateJson(completion)) - ); - - const parsedDataArray = unparsedDataArray - .map((item) => { - const safeResult = copywriter.TextInstance.safeParse(item); - if (safeResult.success) { - return safeResult.data; - } - }) - .filter( - (value: T): value is NonNullable => - value !== undefined - ); - - const operationsToApply = parsedDataArray.filter( - (item) => - appliedOperations.has(JSON.stringify(item)) === false - ); - - for (const operation of operationsToApply) { - patchTextInstance(operation); - appliedOperations.add(JSON.stringify(operation)); - } - } catch (error) { - console.error(error); - } - } - }, - } - ) - ) - ); - - for (const promise of promises) { - if (promise.status === "fulfilled") { - const result = promise.value; - - if (result.success === false) { - throw new AiApiException(result.data.message); - } - - if (result.type !== "json") { - continue; - } - - if (result.id === "operations") { - restoreComponentsNamespace(result.data); - applyOperations(result.data); - continue; - } - } else if (promise.status === "rejected") { - if (promise.reason instanceof Error) { - throw promise.reason; - } - - throw new Error(promise.reason.message); - } - } -}; - -const $availableComponentsNames = computed( - [$registeredComponentMetas], - (metas) => { - const exclude = [ - "Body", - "Slot", - // @todo Remove Radix exclusion when the model has been fine-tuned to understand them. - isFeatureEnabled("aiRadixComponents") - ? "@webstudio-is/sdk-components-react-radix:" - : undefined, - ].filter(function (value: T): value is NonNullable { - return value !== undefined; - }); - - return [...metas.keys()] - .filter((name) => !exclude.some((excluded) => name.startsWith(excluded))) - .map(parseComponentName); - } -); - -const traverseTemplate = ( - template: WsEmbedTemplate, - fn: (node: WsEmbedTemplate[number]) => void -) => { - for (const node of template) { - fn(node); - if (node.type === "instance") { - traverseTemplate(node.children, fn); - } - } -}; - -// The LLM gets a list of available component names -// therefore we need to replace the component namespace with a LLM-friendly one -// preserving context eg. Radix.Dialog instead of just Dialog -const parseComponentName = (name: string) => - name.replace("@webstudio-is/sdk-components-react-radix:", "Radix."); - -// When AI generation is done we need to restore components namespaces. -const restoreComponentsNamespace = (operations: operations.WsOperations) => { - for (const operation of operations) { - if (operation.operation === "insertTemplate") { - traverseTemplate(operation.template, (node) => { - if (node.type === "instance" && node.component.startsWith("Radix.")) { - node.component = - "@webstudio-is/sdk-components-react-radix:" + - node.component.slice("Radix.".length); - } - }); - } - } -}; - -const $jsx = computed( - [ - $selectedInstance, - $instances, - $props, - $dataSources, - $registeredComponentMetas, - $styles, - $styleSourceSelections, - ], - ( - instance, - instances, - props, - dataSources, - metas, - styles, - styleSourceSelections - ) => { - if (instance === undefined) { - return; - } - - const indexesWithinAncestors = getIndexesWithinAncestors(metas, instances, [ - instance.id, - ]); - const scope = createScope(); - - const jsx = generateJsxElement({ - scope, - instance, - props, - dataSources, - usedDataSources: new Map(), - indexesWithinAncestors, - children: generateJsxChildren({ - scope, - children: instance.children, - instances, - props, - dataSources, - usedDataSources: new Map(), - indexesWithinAncestors, - excludePlaceholders: true, - }), - }); - - const treeInstanceIds = findTreeInstanceIds(instances, instance.id); - - const sheet = createRegularStyleSheet({ name: "ssr" }); - for (const styleDecl of styles.values()) { - sheet.addMediaRule(styleDecl.breakpointId); - const rule = sheet.addMixinRule(styleDecl.styleSourceId); - rule.setDeclaration({ - breakpoint: styleDecl.breakpointId, - selector: styleDecl.state ?? "", - property: styleDecl.property, - value: styleDecl.value, - }); - } - for (const { instanceId, values } of styleSourceSelections.values()) { - if (treeInstanceIds.has(instanceId)) { - const rule = sheet.addNestingRule(`[${idAttribute}="${instanceId}"]`); - rule.applyMixins(values); - } - } - - const css = sheet.cssText.replace(/\n/gm, " "); - return [ - css.length > 0 ? `` : "", - jsx - .replace(new RegExp(`${componentAttribute}="[^"]+"`, "g"), "") - .replace(/\n(data-)/g, " $1"), - ]; - } -); diff --git a/apps/builder/app/builder/features/ai/ai-fetch-transcription.ts b/apps/builder/app/builder/features/ai/ai-fetch-transcription.ts deleted file mode 100644 index 02d69e9525bb..000000000000 --- a/apps/builder/app/builder/features/ai/ai-fetch-transcription.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { restAi } from "~/shared/router-utils"; -import type { action } from "~/routes/rest.ai.audio.transcriptions"; -import { - AiApiException, - RateLimitException, - textToRateLimitMeta, -} from "./api-exceptions"; -import { fetch } from "~/shared/fetch.client"; - -export const fetchTranscription = async (file: File) => { - const formData = new FormData(); - - formData.append("file", file); - - const response = await fetch(restAi("audio/transcriptions"), { - method: "POST", - body: formData, - }); - - if (response.ok === false) { - const text = await response.text(); - - if (response.status === 429) { - const meta = textToRateLimitMeta(text); - throw new RateLimitException(text, meta); - } - - throw new Error( - `Fetch error status="${response.status}" text="${text.slice(0, 1000)}"` - ); - } - - // @todo add response parsing - const result: Awaited> = await response.json(); - - if (result.success) { - return result.data.text; - } - - throw new AiApiException(result.error.message); -}; diff --git a/apps/builder/app/builder/features/ai/api-exceptions.ts b/apps/builder/app/builder/features/ai/api-exceptions.ts deleted file mode 100644 index 5bd764cf75ae..000000000000 --- a/apps/builder/app/builder/features/ai/api-exceptions.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { z } from "zod"; - -/** - * To facilitate debugging, categorize errors into few types, one is and API-specific errors. - */ -export class AiApiException extends Error { - constructor(message: string) { - super(message); - } -} - -const zRateLimit = z.object({ - error: z.object({ - message: z.string(), - code: z.number(), - meta: z.object({ - limit: z.number(), - reset: z.number(), - remaining: z.number(), - ratelimitName: z.string(), - }), - }), -}); - -type RateLimitMeta = z.infer["error"]["meta"]; - -export const textToRateLimitMeta = (text: string): RateLimitMeta => { - try { - const { error } = zRateLimit.parse(JSON.parse(text)); - - return error.meta; - } catch { - // If a 429 status code is received and it's not from our API, default to a 1-minute wait time from the current moment. - return { - limit: 0, - remaining: 0, - reset: Date.now(), - ratelimitName: "unparsed", - }; - } -}; - -export class RateLimitException extends Error { - meta: RateLimitMeta; - - constructor(message: string, meta: RateLimitMeta) { - super(message); - this.meta = meta; - } -} diff --git a/apps/builder/app/builder/features/ai/apply-operations.ts b/apps/builder/app/builder/features/ai/apply-operations.ts deleted file mode 100644 index 0db3f76145b5..000000000000 --- a/apps/builder/app/builder/features/ai/apply-operations.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { nanoid } from "nanoid"; -import { getStyleDeclKey, Instance, type StyleSource } from "@webstudio-is/sdk"; -import { generateDataFromEmbedTemplate } from "@webstudio-is/react-sdk"; -import type { copywriter, operations } from "@webstudio-is/ai"; -import { serverSyncStore } from "~/shared/sync"; -import { isBaseBreakpoint } from "~/shared/breakpoints"; -import { - deleteInstanceMutable, - insertWebstudioFragmentAt, - updateWebstudioData, - type Insertable, -} from "~/shared/instance-utils"; -import { - $breakpoints, - $instances, - $registeredComponentMetas, - $selectedInstanceSelector, - $styleSourceSelections, - $styleSources, - $styles, -} from "~/shared/nano-states"; -import type { InstanceSelector } from "~/shared/tree-utils"; -import { $selectedInstance } from "~/shared/awareness"; -import { isInstanceDetachable } from "~/shared/matcher"; - -export const applyOperations = (operations: operations.WsOperations) => { - for (const operation of operations) { - switch (operation.operation) { - case "insertTemplate": - insertTemplateByOp(operation); - break; - case "deleteInstance": - deleteInstanceByOp(operation); - break; - case "applyStyles": - applyStylesByOp(operation); - break; - default: - if (process.env.NODE_ENV === "development") { - console.warn(`Not supported operation: ${operation}`); - } - } - } -}; - -const insertTemplateByOp = ( - operation: operations.generateInsertTemplateWsOperation -) => { - const metas = $registeredComponentMetas.get(); - const templateData = generateDataFromEmbedTemplate(operation.template, metas); - - // @todo Find a way to avoid the workaround below, peharps improving the prompt. - // Occasionally the LLM picks a component name or the entire data-ws-id attribute as the insertion point. - // Instead of throwing the otherwise correct operation we try to fix this here. - if ( - [...metas.keys()].some((componentName) => - componentName.includes(operation.addTo) - ) - ) { - const selectedInstance = $selectedInstance.get(); - if (selectedInstance) { - operation.addTo = selectedInstance.id; - } - } - - const rootInstanceIds = templateData.children - .filter((child) => child.type === "id") - .map((child) => child.value); - - const instanceSelector = computeSelectorForInstanceId(operation.addTo); - if (instanceSelector) { - const currentInstance = $instances.get().get(instanceSelector[0]); - // Only container components are allowed to have child elements. - if ( - currentInstance && - metas.get(currentInstance.component)?.type !== "container" - ) { - return; - } - - const dropTarget: Insertable = { - parentSelector: instanceSelector, - position: operation.addAtIndex + 1, - }; - insertWebstudioFragmentAt(templateData, dropTarget); - return rootInstanceIds; - } -}; - -const deleteInstanceByOp = ( - operation: operations.deleteInstanceWsOperation -) => { - const instanceSelector = computeSelectorForInstanceId(operation.wsId); - if (instanceSelector) { - // @todo tell user they can't delete root - if (instanceSelector.length === 1) { - return; - } - const metas = $registeredComponentMetas.get(); - updateWebstudioData((data) => { - if ( - isInstanceDetachable({ - metas, - instances: data.instances, - instanceSelector, - }) === false - ) { - return; - } - deleteInstanceMutable(data, instanceSelector); - }); - } -}; - -const applyStylesByOp = (operation: operations.editStylesWsOperation) => { - serverSyncStore.createTransaction( - [$styleSourceSelections, $styleSources, $styles, $breakpoints], - (styleSourceSelections, styleSources, styles, breakpoints) => { - const newStyles = [...operation.styles.values()]; - - const breakpointValues = Array.from(breakpoints.values()); - const baseBreakpoint = - breakpointValues.find(isBaseBreakpoint) ?? breakpointValues[0]; - - for (const instanceId of operation.instanceIds) { - const styleSourceSelection = styleSourceSelections.get(instanceId); - let styleSource: StyleSource | undefined; - let styleSourceId: string = ""; - - if (styleSourceSelection) { - for (const id of styleSourceSelection.values) { - const candidateStyleSource = styleSources.get(id); - if (candidateStyleSource && candidateStyleSource.type === "local") { - styleSource = candidateStyleSource; - styleSourceId = candidateStyleSource.id; - break; - } - } - } - - if (styleSourceId === "") { - styleSourceId = nanoid(); - } - - if (styleSource === undefined) { - styleSources.set(styleSourceId, { type: "local", id: styleSourceId }); - } - - if (styleSourceSelection === undefined) { - styleSourceSelections.set(instanceId, { - instanceId, - values: [styleSourceId], - }); - } - - for (const embedStyleDecl of newStyles) { - const styleDecl = { - ...embedStyleDecl, - breakpointId: baseBreakpoint?.id, - styleSourceId, - }; - styles.set(getStyleDeclKey(styleDecl), styleDecl); - } - } - } - ); -}; - -const computeSelectorForInstanceId = (instanceId: Instance["id"]) => { - const selectedInstanceSelector = $selectedInstanceSelector.get(); - if (selectedInstanceSelector === undefined) { - return; - } - - // When the instance is the selected instance return selectedInstanceSelector right away. - if (instanceId === selectedInstanceSelector[0]) { - return selectedInstanceSelector; - } - - // For a given instance to delete we compute the subtree selector between - // that instance and the selected instance (a parent). - let subtreeSelector: InstanceSelector = []; - const parentInstancesById = new Map(); - for (const instance of $instances.get().values()) { - for (const child of instance.children) { - if (child.type === "id") { - parentInstancesById.set(child.value, instance.id); - } - } - } - const selector: InstanceSelector = []; - let currentInstanceId: undefined | Instance["id"] = instanceId; - while (currentInstanceId) { - selector.push(currentInstanceId); - currentInstanceId = parentInstancesById.get(currentInstanceId); - if (currentInstanceId === selectedInstanceSelector[0]) { - subtreeSelector = [...selector, ...selectedInstanceSelector]; - break; - } - } - - if (subtreeSelector.length === 0) { - return; - } - - const parentSelector = selectedInstanceSelector.slice(1); - // Combine the subtree selector with the selected instance one - // to get the full and final selector. - const combinedSelector = [...subtreeSelector, ...parentSelector]; - return combinedSelector; -}; - -export const patchTextInstance = (textInstance: copywriter.TextInstance) => { - serverSyncStore.createTransaction([$instances], (instances) => { - const currentInstance = instances.get(textInstance.instanceId); - - if (currentInstance === undefined) { - return; - } - - const meta = $registeredComponentMetas.get().get(currentInstance.component); - - // Only container components are allowed to have child elements. - if (meta?.type !== "container") { - return; - } - - if (currentInstance.children.length === 0) { - currentInstance.children = [{ type: "text", value: textInstance.text }]; - return; - } - - // Instances can have a number of text child nodes without interleaving components. - // When this is the case we treat the child nodes as a single text node, - // otherwise the AI would generate children.length chunks of separate text. - // We can identify this case of "joint" text instances when the index is -1. - const replaceAll = textInstance.index === -1; - if (replaceAll) { - if (currentInstance.children.every((child) => child.type === "text")) { - currentInstance.children = [{ type: "text", value: textInstance.text }]; - } - return; - } - - if (currentInstance.children[textInstance.index].type === "text") { - currentInstance.children[textInstance.index].value = textInstance.text; - } - }); -}; diff --git a/apps/builder/app/builder/features/ai/hooks/long-press-toggle.ts b/apps/builder/app/builder/features/ai/hooks/long-press-toggle.ts deleted file mode 100644 index a4d42788de67..000000000000 --- a/apps/builder/app/builder/features/ai/hooks/long-press-toggle.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { type SyntheticEvent, type KeyboardEvent, useRef } from "react"; -import { useEffectEvent } from "~/shared/hook-utils/effect-event"; - -type UseClickAndHoldProps = { - onStart: () => void; - onEnd: () => void; - onCancel?: () => void; - longPressDuration?: number; -}; - -/** - * Toggle hook with long-press handling. - * - `onStart`: First click. - * - `onEnd`: Second click or long-press release. - * - `onCancel`: Pointer up outside target during long press. - */ -export const useLongPressToggle = (props: UseClickAndHoldProps) => { - const currentTarget = useRef(undefined); - const pointerDownTimeRef = useRef(0); - const stateRef = useRef<"idle" | "active">("idle"); - const keyMapRef = useRef(new Set()); - - const { longPressDuration = 1000 } = props; - - // Wrap to be sure that latest callback is used in event handlers. - const onStart = useEffectEvent(props.onStart); - const onEnd = useEffectEvent(props.onEnd); - const onCancel = useEffectEvent(props.onCancel); - - const handlePointerUp = useEffectEvent((event: Event) => { - if (stateRef.current === "idle") { - return; - } - const { target } = event; - - if (!(target instanceof Element)) { - return; - } - - const time = Date.now() - pointerDownTimeRef.current; - const isLongPress = time >= longPressDuration; - - if (isLongPress === false) { - return; - } - - if (currentTarget.current?.contains(target)) { - const time = Date.now() - pointerDownTimeRef.current; - if (time >= longPressDuration) { - onEnd(); - stateRef.current = "idle"; - } - return; - } - - onCancel?.(); - stateRef.current = "idle"; - }); - - const onPointerDown = useEffectEvent((event: SyntheticEvent) => { - if (stateRef.current === "active") { - onEnd(); - stateRef.current = "idle"; - return; - } - - stateRef.current = "active"; - currentTarget.current = event.currentTarget; - pointerDownTimeRef.current = Date.now(); - document.addEventListener("pointerup", handlePointerUp, { once: true }); - onStart(); - }); - - const onKeyDown = useEffectEvent((event: KeyboardEvent) => { - if (keyMapRef.current.has(event.code)) { - return; - } - - if (event.code === "Enter" || event.code === "Space") { - keyMapRef.current.add(event.code); - onPointerDown(event); - } - }); - - const onKeyUp = useEffectEvent((event: KeyboardEvent) => { - if (event.code === "Enter" || event.code === "Space") { - keyMapRef.current.delete(event.code); - - handlePointerUp(event.nativeEvent); - } - }); - - return { onPointerDown, onKeyDown, onKeyUp }; -}; diff --git a/apps/builder/app/builder/features/ai/hooks/media-recorder.stories.tsx b/apps/builder/app/builder/features/ai/hooks/media-recorder.stories.tsx deleted file mode 100644 index 67c38ab5ed2e..000000000000 --- a/apps/builder/app/builder/features/ai/hooks/media-recorder.stories.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useRef, useState } from "react"; -import { useLongPressToggle } from "./long-press-toggle"; -import { useMediaRecorder } from "./media-recorder"; -import { Button, Flex, Grid, css } from "@webstudio-is/design-system"; - -export default { - title: "Library/Media Recorder", - argTypes: { - audioBitsPerSecond: { - options: [4000, 8000, 16000, 32000, 64000, 128000, 256000], - control: { type: "radio" }, - }, - }, - args: { - audioBitsPerSecond: 16000, - }, -}; - -const playAudio = (file: File) => { - const blobUrl = URL.createObjectURL(file); - const audioElement = new Audio(blobUrl); - - audioElement.addEventListener("ended", () => { - URL.revokeObjectURL(blobUrl); - }); - - audioElement.play(); -}; - -export const MediaRecorder = (options: { audioBitsPerSecond: number }) => { - const soundRef = useRef(null); - - const [file, setFile] = useState(); - - const { start, stop, cancel, state } = useMediaRecorder( - { - onError: (error) => { - console.error(error); - }, - onComplete: (file) => { - setFile(file); - }, - onReportSoundAmplitude: (amplitude) => { - soundRef.current?.style.setProperty("--volume", amplitude.toString()); - }, - }, - options - ); - - const clickAndHoldProps = useLongPressToggle({ - onStart: start, - onEnd: stop, - onCancel: cancel, - }); - - return ( - - -
-
- - {file && ( - - )} -
- ); -}; -MediaRecorder.storyName = "Media Recorder"; - -const soundCss = css({ - transition: "transform 0.15s ease-in-out", - borderRadius: "9999px", - backgroundColor: "#F56565", - width: "3rem", - height: "3rem", - transformOrigin: "center", - transform: "scale(calc(1 + var(--volume, 0)))", -}); diff --git a/apps/builder/app/builder/features/ai/hooks/media-recorder.ts b/apps/builder/app/builder/features/ai/hooks/media-recorder.ts deleted file mode 100644 index e2cc23fc5ff0..000000000000 --- a/apps/builder/app/builder/features/ai/hooks/media-recorder.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { useRef, useState } from "react"; -import { useEffectEvent } from "~/shared/hook-utils/effect-event"; - -// https://github.com/openai/whisper/discussions/870 -const DEFAULT_OPTIONS: MediaRecorderOptions = { - audioBitsPerSecond: 16000, -}; - -export const useMediaRecorder = ( - props: { - onComplete: (file: File) => void; - onError: (error: unknown) => void; - onReportSoundAmplitude?: (amplitude: number) => void; - }, - options = DEFAULT_OPTIONS -) => { - const disposeRef = useRef void)>(undefined); - - const cancelRef = useRef(false); - const isActiveRef = useRef(false); - const idRef = useRef(0); - const [state, setState] = useState<"inactive" | "recording">("inactive"); - - const onComplete = useEffectEvent(props.onComplete); - const onReportSoundAmplitude = useEffectEvent(props.onReportSoundAmplitude); - - const start = useEffectEvent(async () => { - isActiveRef.current = true; - const chunks: Blob[] = []; - idRef.current++; - const id = idRef.current; - - cancelRef.current = false; - - let stream: MediaStream; - - try { - stream = await navigator.mediaDevices.getUserMedia({ - audio: true, - video: false, - }); - } catch (error) { - // Not allowed, do not start new recording - isActiveRef.current = false; - props.onError(error); - return; - } - - // Recording was stopped, do not start new recording - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore wrong ts error based on isActiveRef.current = true above it think that isActiveRef.current is always true - if (isActiveRef.current === false) { - stream.getAudioTracks().forEach((track) => track.stop()); - return; - } - - // New recording started, do cleanup and return - if (id !== idRef.current) { - stream.getAudioTracks().forEach((track) => track.stop()); - return; - } - - setState("recording"); - - const subtype = MediaRecorder.isTypeSupported("audio/webm; codecs=opus") - ? "webm" - : "mp4"; - const mimeType = - subtype === "webm" ? `audio/${subtype}; codecs=opus` : `audio/${subtype}`; - - const recorder = new MediaRecorder(stream, { - mimeType, - ...options, - }); - - const audioContext = new AudioContext(); - const source = audioContext.createMediaStreamSource(stream); - const analyser = audioContext.createAnalyser(); - source.connect(analyser); - - analyser.fftSize = 2048; - const bufferLength = analyser.fftSize; - const dataArray = new Float32Array(bufferLength); - - disposeRef.current = () => { - source.disconnect(); - analyser.disconnect(); - audioContext.close(); - // Safari bug: Calling `stop` on tracks delays next `getUserMedia` by 3-5s. - // Chrome: `stop` needed to remove recording tab indicator. - // @todo: Probably don't stop tracks in Safari, as subsequent getUserMedia blocks the main thread, and cause long-press logic to fail - stream.getAudioTracks().forEach((track) => track.stop()); - recorder.stop(); - }; - - const latestSamples = Array.from({ length: 10 }, () => 1); - let latestSamplesIndex = 0; - - recorder.ondataavailable = (event) => { - analyser.getFloatTimeDomainData(dataArray); - const sampleMaxAmplitude = Math.max(...dataArray); - latestSamples[latestSamplesIndex] = sampleMaxAmplitude; - latestSamplesIndex = (latestSamplesIndex + 1) % latestSamples.length; - - // To not normalize amplitude around near zero values - const normalizeThreshold = 0.1; - - // Normalize amplitude to be between 0 and 1, and against lastest samples. - // The idea to use latest samples for normalization - onReportSoundAmplitude?.( - sampleMaxAmplitude / - Math.max(normalizeThreshold, Math.max(...latestSamples)) - ); - - // New recording started, do cleanup and return - if (id !== idRef.current) { - chunks.length = 0; - return; - } - - // Cancelled, do cleanup and return - if (cancelRef.current) { - setState("inactive"); - chunks.length = 0; - return; - } - - chunks.push(event.data); - - if (recorder.state === "inactive") { - const audioFile = new File(chunks, "recording." + subtype, { - // Add type to be able to play in audio element - type: "audio/" + subtype, - }); - - if (audioFile.size > 0) { - onComplete(audioFile); - onReportSoundAmplitude?.(0); - } - chunks.length = 0; - setState("inactive"); - } - }; - - recorder.start(50); - }); - - const stop = useEffectEvent(() => { - isActiveRef.current = false; - disposeRef.current?.(); - disposeRef.current = undefined; - }); - - const cancel = useEffectEvent(() => { - cancelRef.current = true; - stop(); - }); - - return { start, stop, cancel, state }; -}; diff --git a/apps/builder/app/builder/features/marketplace/about.tsx b/apps/builder/app/builder/features/marketplace/about.tsx deleted file mode 100644 index de268b395d84..000000000000 --- a/apps/builder/app/builder/features/marketplace/about.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { - Button, - Flex, - Link, - PanelTitle, - Separator, - Text, - Tooltip, - buttonStyle, - theme, - truncate, -} from "@webstudio-is/design-system"; -import type { MarketplaceOverviewItem } from "~/shared/marketplace/types"; -import { ChevronsLeftIcon, ExternalLinkIcon } from "@webstudio-is/icons"; -import { builderUrl } from "~/shared/router-utils"; - -export const About = ({ - item, - onClose, -}: { - item?: MarketplaceOverviewItem; - onClose: () => void; -}) => { - if (item === undefined) { - return; - } - - const hasAuthToken = item.authorizationToken != null; - - return ( - <> - - - - - - - - - - - {Array.from(templatesDataByCategory.keys()) - .sort() - .map((category) => { - return ( - - - - {templatesDataByCategory - .get(category) - ?.map((templateData, index) => { - return ( - { - if (productCategory === "sectionTemplates") { - insertSection({ - data, - instanceId: templateData.rootInstanceId, - }); - } - if ( - productCategory === "pageTemplates" || - productCategory === "integrationTemplates" - ) { - insertPage({ - data, - pageId: templateData.pageId, - }); - } - }} - > - - - ); - })} - - - - ); - })} - - - ); -}; diff --git a/apps/builder/app/builder/features/marketplace/utils.ts b/apps/builder/app/builder/features/marketplace/utils.ts deleted file mode 100644 index bf0664fe919f..000000000000 --- a/apps/builder/app/builder/features/marketplace/utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - getStyleDeclKey, - type Asset, - type WebstudioData, -} from "@webstudio-is/sdk"; -import type { CompactBuild } from "@webstudio-is/project-build"; - -const getPair = (item: Item) => - [item.id, item] as const; - -export const toWebstudioData = ( - data: CompactBuild & { assets: Asset[] } -): WebstudioData => ({ - assets: new Map(data.assets.map(getPair)), - instances: new Map(data.instances.map(getPair)), - dataSources: new Map(data.dataSources.map(getPair)), - resources: new Map(data.resources.map(getPair)), - props: new Map(data.props.map(getPair)), - pages: data.pages, - breakpoints: new Map(data.breakpoints.map(getPair)), - styleSources: new Map(data.styleSources.map(getPair)), - styleSourceSelections: new Map( - data.styleSourceSelections.map((item) => [item.instanceId, item]) - ), - styles: new Map(data.styles.map((item) => [getStyleDeclKey(item), item])), -}); diff --git a/apps/builder/app/builder/features/pages/page-settings.stories.tsx b/apps/builder/app/builder/features/pages/page-settings.stories.tsx index 279f07c4e014..cbb1b0928f3a 100644 --- a/apps/builder/app/builder/features/pages/page-settings.stories.tsx +++ b/apps/builder/app/builder/features/pages/page-settings.stories.tsx @@ -68,8 +68,6 @@ $project.set({ userId: "userId", domain: "new-2x9tcd", - marketplaceApprovalStatus: "UNLISTED", - latestStaticBuild: null, previewImageAssetId: null, previewImageAsset: { diff --git a/apps/builder/app/builder/features/pages/page-settings.tsx b/apps/builder/app/builder/features/pages/page-settings.tsx index 573e52d95622..3fff451de15e 100644 --- a/apps/builder/app/builder/features/pages/page-settings.tsx +++ b/apps/builder/app/builder/features/pages/page-settings.tsx @@ -109,7 +109,6 @@ import { import { Form } from "./form"; import type { UserPlanFeatures } from "~/shared/db/user-plan-features.server"; import { useUnmount } from "~/shared/hook-utils/use-mount"; -import { Card } from "../marketplace/card"; import { selectInstance } from "~/shared/awareness"; import { computeExpression } from "~/shared/data-variables"; @@ -128,9 +127,6 @@ const fieldDefaultValues = { redirect: `""`, documentType: "html" as (typeof documentTypes)[number], customMetas: [{ property: "", content: `""` }], - marketplaceInclude: false, - marketplaceCategory: "", - marketplaceThumbnailAssetId: "", }; const fieldNames = Object.keys( @@ -317,9 +313,6 @@ const toFormValues = ( documentType: page.meta.documentType ?? fieldDefaultValues.documentType, isHomePage, customMetas: page.meta.custom ?? fieldDefaultValues.customMetas, - marketplaceInclude: page.marketplace?.include ?? false, - marketplaceCategory: page.marketplace?.category ?? "", - marketplaceThumbnailAssetId: page.marketplace?.thumbnailAssetId ?? "", }; }; @@ -609,101 +602,6 @@ const fieldsetStyle = css({ }, }); -const MarketplaceSection = ({ - values, - onChange, -}: { - values: Values; - onChange: OnChange; -}) => { - const excludeId = useId(); - const categoryId = useId(); - const categoryMeta = values.customMetas.find( - ({ property }) => property === "ws:category" - ); - // @todo remove after all stores are migrated - const categoryFallback = String( - computeExpression(categoryMeta?.content ?? `""`, new Map()) - ); - const category = values.marketplaceCategory ?? categoryFallback ?? "Pages"; - const assets = useStore($assets); - const thumbnailAsset = assets.get(values.marketplaceThumbnailAssetId); - const thumnailFallbackAsset = assets.get(values.socialImageAssetId); - return ( - - - - - onChange({ field: "marketplaceInclude", value }) - } - /> - - - - - - onChange({ - field: "marketplaceCategory", - value: event.target.value, - }) - } - /> - - - - onChange({ field: "marketplaceThumbnailAssetId", value }) - } - > - - - - {thumbnailAsset?.type === "image" && ( - - onChange({ field: "marketplaceThumbnailAssetId", value: "" }) - } - /> - )} - - - - - {category && } - - - - - - ); -}; - const FormFields = ({ systemDataSourceId, autoSelect, @@ -1224,15 +1122,6 @@ const FormFields = ({ - {(project?.marketplaceApprovalStatus === "PENDING" || - project?.marketplaceApprovalStatus === "APPROVED" || - project?.marketplaceApprovalStatus === "REJECTED") && ( - <> - - - - )} - @@ -1471,25 +1360,6 @@ const updatePage = (pageId: Page["id"], values: Partial) => { if (values.parentFolderId !== undefined) { registerFolderChildMutable(folders, page.id, values.parentFolderId); } - - if (values.marketplaceInclude !== undefined) { - page.marketplace ??= {}; - page.marketplace.include = values.marketplaceInclude; - } - if (values.marketplaceCategory !== undefined) { - page.marketplace ??= {}; - page.marketplace.category = - values.marketplaceCategory.length > 0 - ? values.marketplaceCategory - : undefined; - } - if (values.marketplaceThumbnailAssetId !== undefined) { - page.marketplace ??= {}; - page.marketplace.thumbnailAssetId = - values.marketplaceThumbnailAssetId.length > 0 - ? values.marketplaceThumbnailAssetId - : undefined; - } }; serverSyncStore.createTransaction([$pages], (pages) => { diff --git a/apps/builder/app/builder/features/project-settings/project-settings.tsx b/apps/builder/app/builder/features/project-settings/project-settings.tsx index 833fd2869ad1..c6640c15e123 100644 --- a/apps/builder/app/builder/features/project-settings/project-settings.tsx +++ b/apps/builder/app/builder/features/project-settings/project-settings.tsx @@ -15,18 +15,16 @@ import { $openProjectSettings } from "~/shared/nano-states/project-settings"; import { SectionGeneral } from "./section-general"; import { SectionRedirects } from "./section-redirects"; import { SectionPublish } from "./section-publish"; -import { SectionMarketplace } from "./section-marketplace"; import { leftPanelWidth, rightPanelWidth } from "./utils"; import type { FunctionComponent } from "react"; import { $isDesignMode } from "~/shared/nano-states"; -type SectionName = "general" | "redirects" | "publish" | "marketplace"; +type SectionName = "general" | "redirects" | "publish"; const sections = new Map([ ["general", SectionGeneral], ["redirects", SectionRedirects], ["publish", SectionPublish], - ["marketplace", SectionMarketplace], ] as const); export const ProjectSettingsView = ({ @@ -105,7 +103,6 @@ export const ProjectSettingsView = ({ {currentSection === "general" && } {currentSection === "redirects" && } {currentSection === "publish" && } - {currentSection === "marketplace" && }
diff --git a/apps/builder/app/builder/features/project-settings/section-marketplace.tsx b/apps/builder/app/builder/features/project-settings/section-marketplace.tsx deleted file mode 100644 index 35020cf797d5..000000000000 --- a/apps/builder/app/builder/features/project-settings/section-marketplace.tsx +++ /dev/null @@ -1,357 +0,0 @@ -import { useStore } from "@nanostores/react"; -import { - Grid, - InputField, - Label, - theme, - Text, - TextArea, - Button, - css, - Flex, - CheckboxAndLabel, - Checkbox, - InputErrorsTooltip, - PanelBanner, - Select, - Box, -} from "@webstudio-is/design-system"; -import { Image, wsImageLoader } from "@webstudio-is/image"; -import { useState } from "react"; -import { - MarketplaceProduct, - marketplaceCategories, -} from "@webstudio-is/project-build"; -import { ImageControl } from "./image-control"; -import { $assets, $marketplaceProduct, $project } from "~/shared/nano-states"; -import { useIds } from "~/shared/form-utils"; -import { MarketplaceApprovalStatus } from "@webstudio-is/project"; -import { serverSyncStore } from "~/shared/sync"; -import { trpcClient } from "~/shared/trpc/trpc-client"; -import { rightPanelWidth, sectionSpacing } from "./utils"; - -const thumbnailStyle = css({ - borderRadius: theme.borderRadius[4], - outlineWidth: 1, - outlineStyle: "solid", - outlineColor: theme.colors.borderMain, - width: theme.spacing[28], - aspectRatio: "1.91", - background: "#DFE3E6", -}); - -const thumbnailImageStyle = css({ - display: "block", - width: "100%", - height: "100%", - variants: { - hasAsset: { - true: { - objectFit: "cover", - }, - }, - }, -}); - -const defaultMarketplaceProduct: Partial = { - category: "sectionTemplates", -}; - -const validate = (data: MarketplaceProduct) => { - const parsedResult = MarketplaceProduct.safeParse(data); - if (parsedResult.success === false) { - return parsedResult.error.formErrors.fieldErrors; - } -}; - -const useMarketplaceApprovalStatus = () => { - const { send, data, state } = - trpcClient.project.setMarketplaceApprovalStatus.useMutation(); - const project = useStore($project); - - const status = - data?.marketplaceApprovalStatus ?? - project?.marketplaceApprovalStatus ?? - "UNLISTED"; - - const handleSuccess = ({ - marketplaceApprovalStatus, - }: { - marketplaceApprovalStatus: MarketplaceApprovalStatus; - }) => { - const project = $project.get(); - if (project) { - $project.set({ - ...project, - marketplaceApprovalStatus, - }); - } - }; - - return { - status, - state, - submit() { - if (project) { - send( - { - projectId: project.id, - marketplaceApprovalStatus: "PENDING", - }, - handleSuccess - ); - } - }, - unlist() { - if (project) { - send( - { - projectId: project.id, - marketplaceApprovalStatus: "UNLISTED", - }, - handleSuccess - ); - } - }, - }; -}; - -export const SectionMarketplace = () => { - const project = useStore($project); - const approval = useMarketplaceApprovalStatus(); - const [data, setData] = useState(() => $marketplaceProduct.get()); - const ids = useIds([ - "name", - "category", - "author", - "email", - "website", - "issues", - "description", - "isConfirmed", - ]); - const assets = useStore($assets); - const [isConfirmed, setIsConfirmed] = useState(false); - const [errors, setErrors] = useState>(); - - if (data === undefined || project === undefined) { - return; - } - const asset = assets.get(data.thumbnailAssetId ?? ""); - - const handleSave = ( - setting: Setting - ) => { - return (value: MarketplaceProduct[Setting]) => { - const nextData = { - ...defaultMarketplaceProduct, - ...data, - [setting]: value, - }; - const errors = validate(nextData); - setErrors(errors); - setData(nextData); - - if (errors) { - return; - } - serverSyncStore.createTransaction( - [$marketplaceProduct], - (marketplaceProduct) => { - if (marketplaceProduct === undefined) { - return; - } - Object.assign(marketplaceProduct, nextData); - } - ); - }; - }; - - return ( - - - Marketplace - - - - - { - handleSave("name")(event.target.value); - }} - /> - - - - - -