mirror of
https://github.com/XFox111/TabsAsideExtension.git
synced 2026-04-22 07:58:01 +03:00
!feat: major 3.0 release candidate
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
FROM mcr.microsoft.com/devcontainers/base:focal
|
||||||
|
|
||||||
|
RUN apt update && apt upgrade -y
|
||||||
|
|
||||||
|
RUN apt install -y software-properties-common apt-transport-https ca-certificates curl gnupg
|
||||||
|
|
||||||
|
RUN mkdir -p /etc/apt/keyrings
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
||||||
|
|
||||||
|
RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
|
||||||
|
RUN apt update && apt install -y nodejs
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
RUN echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list
|
||||||
|
RUN curl -fSsL https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor | sudo tee /usr/share/keyrings/google-chrome.gpg >> /dev/null
|
||||||
|
RUN echo deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main | sudo tee /etc/apt/sources.list.d/google-chrome.list
|
||||||
|
|
||||||
|
RUN apt update && apt install -y google-chrome-stable firefox
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||||
|
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
|
||||||
|
{
|
||||||
|
"name": "TabsAsideExtension",
|
||||||
|
"build": {
|
||||||
|
"dockerfile": "Dockerfile"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Configure tool-specific properties.
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"zardoy.disable-ts-errors",
|
||||||
|
"github.vscode-github-actions",
|
||||||
|
"GitHub.vscode-pull-request-github",
|
||||||
|
"bierner.github-markdown-preview",
|
||||||
|
"mrmlnc.vscode-scss",
|
||||||
|
"Gruntfuggly.todo-tree",
|
||||||
|
"redhat.vscode-yaml"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"postCreateCommand": "yarn install"
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
name: "🐞 Bug Report"
|
||||||
|
description: Create a report to help us improve the extension
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["bug", "needs-triage"]
|
||||||
|
assignees:
|
||||||
|
- xfox111
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to fill out this bug report!
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: desc
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: A clear and concise description of what the bug is.
|
||||||
|
placeholder: e.g. Sometimes when generating a password not all character sets are included
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Reproduction steps
|
||||||
|
description: Precisely describe minimal number of steps that make the bug to appear
|
||||||
|
placeholder: |
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. Scroll down to '...'
|
||||||
|
4. See '...'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
description: A clear and concise description of what you expected to happen.
|
||||||
|
placeholder: e.g. Generated password should include at least one character from every enabled character set
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Screenshot
|
||||||
|
description: If applicable, add screenshots to help explain your problem.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: Operating system
|
||||||
|
options:
|
||||||
|
- "Windows 10 and newer"
|
||||||
|
- "Windows 8/8.1"
|
||||||
|
- "Windows 7 and older"
|
||||||
|
- "MacOS"
|
||||||
|
- "Debian or Debian-based"
|
||||||
|
- "Other"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: browser
|
||||||
|
attributes:
|
||||||
|
label: Browser name and version
|
||||||
|
placeholder: e.g. Microsoft Edge 119.0.2151.58
|
||||||
|
description: Put here your browser's name and version
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Extension version
|
||||||
|
placeholder: e.g. 3.0.0
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Add any other context about the problem here.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: requested-help
|
||||||
|
attributes:
|
||||||
|
label: Are you willing to submit a PR for this issue?
|
||||||
|
options:
|
||||||
|
- "yes"
|
||||||
|
- "no"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Validations
|
||||||
|
description: Before submitting the issue, please make sure you do the following
|
||||||
|
options:
|
||||||
|
- label: Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.
|
||||||
|
required: true
|
||||||
|
- label: The provided reproduction is a minimal reproducible example of the bug.
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# yaml-language-server: $schema=https://json.schemastore.org/github-issue-config.json
|
||||||
|
|
||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: Questions & Discussions
|
||||||
|
url: https://github.com/XFox111/TabsAsideExtension/discussions
|
||||||
|
about: Use GitHub discussions for message-board style questions and discussions.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
name: "🚀 New feature proposal"
|
||||||
|
description: Suggest a feature idea for this project
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels: ["feature", "needs-triage"]
|
||||||
|
assignees:
|
||||||
|
- xfox111
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for your interest in the project and taking the time to fill out this feature report!
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposition
|
||||||
|
attributes:
|
||||||
|
label: Proposed solution
|
||||||
|
description: Describe the solution you'd like
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: justification
|
||||||
|
attributes:
|
||||||
|
label: Justification
|
||||||
|
description: Is your feature request related to a problem? Please describe.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alts
|
||||||
|
attributes:
|
||||||
|
label: Alternatives
|
||||||
|
description: Describe alternatives you've considered.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Add any other context or screenshots about the feature request here.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: requested-help
|
||||||
|
attributes:
|
||||||
|
label: Are you willing to submit a PR for this issue?
|
||||||
|
options:
|
||||||
|
- "yes"
|
||||||
|
- "no"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Validations
|
||||||
|
description: Before submitting the issue, please make sure you do the following
|
||||||
|
options:
|
||||||
|
- label: Check that there isn't already an issue that request the same feature to avoid creating a duplicate.
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for all configuration options:
|
||||||
|
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||||
|
|
||||||
|
# yaml-language-server: $schema=https://json.schemastore.org/dependabot-2.0.json
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
|
||||||
|
- package-ecosystem: "npm" # See documentation for possible values
|
||||||
|
directory: "/" # Location of package manifests
|
||||||
|
target-branch: "next"
|
||||||
|
assignees:
|
||||||
|
- "xfox111"
|
||||||
|
reviewers:
|
||||||
|
- "xfox111"
|
||||||
|
schedule:
|
||||||
|
interval: monthly
|
||||||
|
rebase-strategy: disabled
|
||||||
|
open-pull-requests-limit: 20
|
||||||
|
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
target-branch: "next"
|
||||||
|
assignees:
|
||||||
|
- "xfox111"
|
||||||
|
reviewers:
|
||||||
|
- "xfox111"
|
||||||
|
schedule:
|
||||||
|
interval: monthly
|
||||||
|
rebase-strategy: disabled
|
||||||
|
open-pull-requests-limit: 20
|
||||||
|
|
||||||
|
- package-ecosystem: "devcontainers"
|
||||||
|
directory: "/"
|
||||||
|
target-branch: "next"
|
||||||
|
assignees:
|
||||||
|
- "xfox111"
|
||||||
|
reviewers:
|
||||||
|
- "xfox111"
|
||||||
|
schedule:
|
||||||
|
interval: monthly
|
||||||
|
rebase-strategy: disabled
|
||||||
|
open-pull-requests-limit: 20
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!-- ⚠️ Make sure that you create this PR against `next` branch and not `main` -->
|
||||||
|
|
||||||
|
## Description
|
||||||
|
<!--Put short description of the pull request here-->
|
||||||
|
|
||||||
|
Resolves: #issue_number
|
||||||
|
|
||||||
|
<!-- ------------------------------------- -->
|
||||||
|
<!-- FOR REPOSITORY MAINTAINERS' PRS ONLY! -->
|
||||||
|
<!-- DO NOT INCLUDE FOLLOWING IN YOUR PR!! -->
|
||||||
|
<!-- ------------------------------------- -->
|
||||||
|
|
||||||
|
<!-- > ## 🚀 Patch Tuesday update
|
||||||
|
> This pull request is a part of our new initiative!
|
||||||
|
From now on we are starting to roll out updates on every first Tuesday of the month, which will include bugfixes, security and dependency updates to keep the project's security and stability up to date!
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Dependencies update and security fixes
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
### Dependency bumps
|
||||||
|
- #
|
||||||
|
### Fixed security vulnerabilities
|
||||||
|
- [CWE-20](https://cwe.mitre.org/data/definitions/20.html) (#)
|
||||||
|
- CVE-2022-25883 (#)
|
||||||
|
|
||||||
|
## PR Checklist
|
||||||
|
- [ ] Update version in `package.json`
|
||||||
|
- [ ] [Post-merge] Review and publish GitHub release
|
||||||
|
- [ ] Update Discussions
|
||||||
|
- [ ] [Post-deploy] Update changelog for Firefox webstore
|
||||||
|
- [ ] Reset `next` branch to be in sync with `main`
|
||||||
|
-->
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!-- > ## 🚀 Patch Tuesday update
|
||||||
|
> This release is a part of our new initiative!
|
||||||
|
From now on we are starting to roll out updates on every first Tuesday of the month, which will include bugfixes, security and dependency updates to keep the project's security and stability up to date!
|
||||||
|
-->
|
||||||
|
|
||||||
|
## What's new
|
||||||
|
<!-- - Dependency updates and security patches (#) -->
|
||||||
|
|
||||||
|
<!-- ### Fixed security issues in this update
|
||||||
|
- [CWE-20](https://cwe.mitre.org/data/definitions/20.html)
|
||||||
|
- CVE-2022-25883
|
||||||
|
-->
|
||||||
|
|
||||||
|
Refer to [Download section of the README.md](https://github.com/XFox111/TabsAsideExtension#download) for sideloading instructions and download links
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
name: Release pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [ released ]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
bypass_audit:
|
||||||
|
description: Bypass npm audit
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
targets:
|
||||||
|
description: Targets
|
||||||
|
required: true
|
||||||
|
default: '["chrome","firefox"]'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- '["chrome","firefox"]'
|
||||||
|
- '["chrome"]'
|
||||||
|
- '["firefox"]'
|
||||||
|
firefox:
|
||||||
|
description: Deploy Firefox
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
chrome:
|
||||||
|
description: Deploy Chrome
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
edge:
|
||||||
|
description: Deploy Edge
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
gh-release:
|
||||||
|
description: Attach to GitHub release
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: node:20
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target: ${{ fromJSON(github.event.inputs.targets || '["chrome","firefox"]') }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@main
|
||||||
|
|
||||||
|
- run: yarn install
|
||||||
|
- run: yarn zip -b ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Drop build artifacts (${{ matrix.target }})
|
||||||
|
uses: actions/upload-artifact@main
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.target }}
|
||||||
|
path: ./.output/tabs-aside-*-${{ matrix.target }}.zip
|
||||||
|
include-hidden-files: true
|
||||||
|
|
||||||
|
- name: web-ext lint
|
||||||
|
if: ${{ matrix.target == 'firefox' }}
|
||||||
|
uses: freaktechnik/web-ext-lint@main
|
||||||
|
with:
|
||||||
|
extension-root: ./.output/firefox-mv3
|
||||||
|
self-hosted: false
|
||||||
|
|
||||||
|
- run: yarn audit
|
||||||
|
if: ${{ github.event_name == 'release' || github.event.inputs.bypass_audit == 'false' }}
|
||||||
|
|
||||||
|
publish-github:
|
||||||
|
needs: build
|
||||||
|
if: ${{ github.event_name == 'release' || github.event.inputs.gh-release == 'true' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target: ${{ fromJSON(github.event.inputs.targets || '["chrome","firefox"]') }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@main
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Attach build to release
|
||||||
|
uses: xresloader/upload-to-github-release@v1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
file: tabs-aside-*-${{ matrix.target }}.zip
|
||||||
|
draft: false
|
||||||
|
overwrite: true
|
||||||
|
update_latest_release: true
|
||||||
|
|
||||||
|
publish-chrome:
|
||||||
|
needs: build
|
||||||
|
if: ${{ github.event_name == 'release' || (github.event.inputs.chrome == 'true' && contains(github.event.inputs.targets, 'chrome')) }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@main
|
||||||
|
with:
|
||||||
|
name: chrome
|
||||||
|
|
||||||
|
- uses: wdzeng/chrome-extension@v1.3.0
|
||||||
|
with:
|
||||||
|
extension-id: mgmjbodjgijnebfgohlnjkegdpbdjgin
|
||||||
|
zip-path: tabs-aside-*-chrome.zip
|
||||||
|
client-id: ${{ secrets.CHROME_CLIENT_ID }}
|
||||||
|
client-secret: ${{ secrets.CHROME_CLIENT_SECRET }}
|
||||||
|
refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }}
|
||||||
|
|
||||||
|
publish-edge:
|
||||||
|
needs: build
|
||||||
|
if: ${{ github.event_name == 'release' || (github.event.inputs.edge == 'true' && contains(github.event.inputs.targets, 'chrome')) }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@main
|
||||||
|
with:
|
||||||
|
name: chrome
|
||||||
|
|
||||||
|
- uses: wdzeng/edge-addon@v2.1.0
|
||||||
|
with:
|
||||||
|
product-id: ${{ secrets.EDGE_PRODUCT_ID }}
|
||||||
|
zip-path: tabs-aside-*-chrome.zip
|
||||||
|
client-id: ${{ secrets.EDGE_CLIENT_ID }}
|
||||||
|
api-key: ${{ secrets.EDGE_API_KEY }}
|
||||||
|
|
||||||
|
publish-firefox:
|
||||||
|
needs: build
|
||||||
|
if: ${{ github.event_name == 'release' || (github.event.inputs.firefox == 'true' && contains(github.event.inputs.targets, 'firefox')) }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@main
|
||||||
|
with:
|
||||||
|
name: firefox
|
||||||
|
|
||||||
|
- uses: wdzeng/firefox-addon@v1.1.2
|
||||||
|
with:
|
||||||
|
addon-guid: ${{ secrets.FIREFOX_EXT_UUID }}
|
||||||
|
xpi-path: tabs-aside-*-firefox.zip
|
||||||
|
jwt-issuer: ${{ secrets.FIREFOX_API_KEY }}
|
||||||
|
jwt-secret: ${{ secrets.FIREFOX_CLIENT_SECRET }}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# For most projects, this workflow file will not need changing; you simply need
|
||||||
|
# to commit it to your repository.
|
||||||
|
#
|
||||||
|
# You may wish to alter this file to override the set of languages analyzed,
|
||||||
|
# or to provide custom queries or build logic.
|
||||||
|
#
|
||||||
|
# ******** NOTE ********
|
||||||
|
# We have attempted to detect the languages in your repository. Please check
|
||||||
|
# the `language` matrix defined below to confirm you have the correct set of
|
||||||
|
# supported CodeQL languages.
|
||||||
|
#
|
||||||
|
name: "CodeQL"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main", "next" ]
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- 'LICENSE'
|
||||||
|
- '**/cd_pipeline.yaml'
|
||||||
|
- '**/dependabot.yml'
|
||||||
|
- '**/pr_pipeline.yaml'
|
||||||
|
- '.vscode/*'
|
||||||
|
pull_request:
|
||||||
|
# The branches below must be a subset of the branches above
|
||||||
|
branches: [ "main", "next" ]
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- 'LICENSE'
|
||||||
|
- '**/cd_pipeline.yaml'
|
||||||
|
- '**/dependabot.yml'
|
||||||
|
- '**/pr_pipeline.yaml'
|
||||||
|
- '.vscode/*'
|
||||||
|
schedule:
|
||||||
|
- cron: '24 7 * * 3'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [ 'typescript' ]
|
||||||
|
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||||
|
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
# Initializes the CodeQL tools for scanning.
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v3
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
|
# By default, queries listed here will override any specified in a config file.
|
||||||
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
|
|
||||||
|
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||||
|
# queries: security-extended,security-and-quality
|
||||||
|
|
||||||
|
|
||||||
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v3
|
||||||
|
|
||||||
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
|
|
||||||
|
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||||
|
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||||
|
|
||||||
|
# - run: |
|
||||||
|
# echo "Run, Build Application using script"
|
||||||
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v3
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
name: PR next workflow
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-release-draft:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@main
|
||||||
|
|
||||||
|
- name: Get version from package.json
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
extver=`jq -r ".version" package.json`
|
||||||
|
echo "version=$extver" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- uses: dev-build-deploy/release-me@v0.18.0
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
prefix: v
|
||||||
|
draft: true
|
||||||
|
version: v${{ steps.get_version.outputs.version }}
|
||||||
|
release-notes: .github/release_description_template.md
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: PR check pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main", "next" ]
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- 'LICENSE'
|
||||||
|
- 'PRIVACY'
|
||||||
|
- '**/cd_pipeline.yaml'
|
||||||
|
- '**/dependabot.yml'
|
||||||
|
- '**/codeql-analysis.yml'
|
||||||
|
- '**/pr_next.yaml'
|
||||||
|
- '.vscode/*'
|
||||||
|
- '.devcontainer/*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
targets:
|
||||||
|
description: Targets
|
||||||
|
required: true
|
||||||
|
default: '["chrome","firefox"]'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- '["chrome","firefox"]'
|
||||||
|
- '["chrome"]'
|
||||||
|
- '["firefox"]'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: node:23
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target: ${{ fromJSON(github.event.inputs.targets || '["chrome","firefox"]') }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@main
|
||||||
|
|
||||||
|
- run: yarn install
|
||||||
|
- run: yarn zip -b ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Drop artifacts (${{ matrix.target }})
|
||||||
|
uses: actions/upload-artifact@main
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.target }}
|
||||||
|
path: ./.output/tabs-aside-*-${{ matrix.target }}.zip
|
||||||
|
include-hidden-files: true
|
||||||
|
|
||||||
|
- name: web-ext lint
|
||||||
|
if: ${{ matrix.target == 'firefox' }}
|
||||||
|
uses: freaktechnik/web-ext-lint@main
|
||||||
|
with:
|
||||||
|
extension-root: ./.output/firefox-mv3
|
||||||
|
self-hosted: false
|
||||||
|
|
||||||
|
- run: yarn audit
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.output
|
||||||
|
stats.html
|
||||||
|
stats-*.json
|
||||||
|
.wxt
|
||||||
|
web-ext.config.ts
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"bierner.github-markdown-preview",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"github.vscode-github-actions",
|
||||||
|
"Gruntfuggly.todo-tree",
|
||||||
|
"jock.svg",
|
||||||
|
"ms-azuretools.vscode-docker",
|
||||||
|
"saeris.markdown-github-alerts",
|
||||||
|
"zardoy.disable-ts-errors"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import DialogProvider from "@/contexts/DialogProvider";
|
||||||
|
import ThemeProvider from "@/contexts/ThemeProvider";
|
||||||
|
|
||||||
|
const App: React.FC<React.PropsWithChildren> = ({ children }: React.PropsWithChildren) =>
|
||||||
|
<ThemeProvider>
|
||||||
|
<DialogProvider>
|
||||||
|
{ children }
|
||||||
|
</DialogProvider>
|
||||||
|
</ThemeProvider>;
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||||
|
identity and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the overall
|
||||||
|
community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||||
|
any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email address,
|
||||||
|
without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official email address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
[opensource@xfox111.net](mailto:opensource@xfox111.net).
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series of
|
||||||
|
actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or permanent
|
||||||
|
ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||||
|
community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.1, available at
|
||||||
|
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by
|
||||||
|
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||||
|
[https://www.contributor-covenant.org/translations][translations].
|
||||||
|
|
||||||
|
> Contributor Covenant is released under the [Creative Commons Attribution 4.0 International Public License](https://github.com/EthicalSource/contributor_covenant/blob/release/LICENSE.md).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||||
|
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||||
|
[FAQ]: https://www.contributor-covenant.org/faq
|
||||||
|
[translations]: https://www.contributor-covenant.org/translations
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Contribution Guidelines
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> This article has been moved to the [project's Wiki section](https://github.com/XFox111/TabsAsideExtension/wiki/Contribution-Guidelines)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Eugene Fox
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Tabs aside extension Privacy policy
|
||||||
|
1. Developers of the extension don't affiliate with Google LLC, Mozilla Foundation or Microsoft Corporation in any way.
|
||||||
|
2. This extension only stores user data related to its core functionality. This includes:
|
||||||
|
- User settings
|
||||||
|
- User saved collections of tabs
|
||||||
|
2. This extension doesn't use any tracking software, nor does it collect, sell or share any personal data with any third parties.
|
||||||
|
3. This extension uses cloud storage built into your browser to store its data.
|
||||||
|
4. Refer to your browser's developer regarding the privacy policy of the cloud storage used by your browser.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
[](https://github.com/xfox111/TabsAsideExtension/releases/latest)
|
||||||
|
[](https://github.com/XFox111/TabsAsideExtension/commits/main)
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://cdn.xfox111.net/projects/tabs-aside/dark.webp">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://cdn.xfox111.net/projects/tabs-aside/light.webp">
|
||||||
|
<img alt="Password generator">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
Stemming its roots from the original Microsoft Edge browser feature, this extension has grown much bigger than just a temporary storage for tabs.
|
||||||
|
|
||||||
|
It allows you to save and manage your tabs in a convenient way, providing a range of features that make it easy to organize and access your saved tabs.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- **Save tabs**: Save all your open tabs in a single click, and restore them later
|
||||||
|
- **Organize tabs**: Create collections and subgroups to organize your saved tabs
|
||||||
|
- **Search tabs**: Quickly find the tabs you need using the search feature
|
||||||
|
- **Sync across devices**: Access your saved tabs from any device with your account
|
||||||
|
- **Go dark**: Dark mode support for a more comfortable browsing experience
|
||||||
|
- **Personalize**: Change the appearance and behavior of the extension to suit your needs
|
||||||
|
|
||||||
|
Check out our [latest blog post](https://at.xfox111.net/tabs-aside-3-0) regarding all the new features and improvements in Tabs aside 3.0
|
||||||
|
|
||||||
|
## Languages
|
||||||
|
- Chinese (Simplified)
|
||||||
|
- English
|
||||||
|
- Italian
|
||||||
|
- Polish
|
||||||
|
- Portuguese (Brazil)
|
||||||
|
- Russian
|
||||||
|
- Spanish
|
||||||
|
- Ukrainian
|
||||||
|
|
||||||
|
## Download
|
||||||
|
[](https://chrome.google.com/webstore/detail/mgmjbodjgijnebfgohlnjkegdpbdjgin)
|
||||||
|
[](https://addons.mozilla.org/firefox/addon/ms-edge-tabs-aside/)
|
||||||
|
|
||||||
|
- [Google Chrome Webstore](https://chrome.google.com/webstore/detail/mgmjbodjgijnebfgohlnjkegdpbdjgin)
|
||||||
|
- [Microsoft Edge Add-ons Webstore](https://microsoftedge.microsoft.com/addons/detail/kmnblllmalkiapkfknnlpobmjjdnlhnd)
|
||||||
|
- [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/ms-edge-tabs-aside/)
|
||||||
|
- [GitHub Releases](https://github.com/xfox111/TabsAsideExtension/releases/latest)
|
||||||
|
|
||||||
|
### Sideloading (for testing purposes only)
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Click to expand</summary>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Chromium-based browsers (Edge, Chrome, etc.)</b></summary>
|
||||||
|
|
||||||
|
> 1. Go to [Releases](https://github.com/XFox111/TabsAsideExtension/releases) and select a release to download
|
||||||
|
> 2. Download attached archive for Chromium and unpack it
|
||||||
|
> 3. Go to `chrome://extensions`
|
||||||
|
> 4. Enable "Developer mode"
|
||||||
|
> 5. Click the "Load unpacked" button and navigate to the extension's root folder (contains `manifest.json`)
|
||||||
|
> 6. Done!
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Firefox</b></summary>
|
||||||
|
|
||||||
|
> 1. Go to [Releases](https://github.com/XFox111/TabsAsideExtension/releases) and select a release to download
|
||||||
|
> 2. Download attached archive for Firefox and unpack it
|
||||||
|
> 3. Go to `about:debugging#/runtime/this-firefox`
|
||||||
|
> 4. Click the "Load Temporary Add-on..." button and select `manifest.json` file in the root folder
|
||||||
|
> 5. Done!
|
||||||
|
|
||||||
|
> **Important!**
|
||||||
|
This will _replace_ officialy installed version if you have one.
|
||||||
|
If you want to sideload it without replacing to run both versions at the same time - before loading add-on, open `manifest.json` in a text editor and change `id` key (it's `passwordgenerator@xfox111.net` by default) to something else
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
> **Note:** If you delete the extension folder it will disappear from your browser
|
||||||
|
---
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
[](https://github.com/xfox111/TabsAsideExtension/issues)
|
||||||
|
[](https://github.com/XFox111/TabsAsideExtension/actions/workflows/cd_pipeline.yaml)
|
||||||
|
[](https://github.com/xfox111/TabsAsideExtension)
|
||||||
|
|
||||||
|
There are many ways in which you can participate in the project, for example:
|
||||||
|
- [Submit bugs and feature requests](https://github.com/xfox111/TabsAsideExtension/issues), and help us verify as they are checked in
|
||||||
|
- Review [source code changes](https://github.com/xfox111/TabsAsideExtension/pulls)
|
||||||
|
- Review documentation and make pull requests for anything from typos to new content
|
||||||
|
|
||||||
|
If you are interested in fixing issues and contributing directly to the code base, please refer to the [Contribution Guidelines](https://github.com/XFox111/TabsAsideExtension/wiki/Contribution-Guidelines)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[](https://bsky.app/profile/xfox111.net)
|
||||||
|
[](https://github.com/xfox111)
|
||||||
|
[](https://buymeacoffee.com/xfox111)
|
||||||
|
|
||||||
|
> ©2025 Eugene Fox. Licensed under [MIT license](https://github.com/XFox111/TabsAsideExtension/blob/main/LICENSE)
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
Tabs aside extension has a linear versioning system. The latest version is always the most secure one. This is applied to major versions as well. If you are using an older version, please update it to the latest one.
|
||||||
|
|
||||||
|
We regularly run security audits and fix any security issues that are found. If you find a security issue, please report it to us as described below.
|
||||||
|
|
||||||
|
If you are willing to continue using an older version, you can sideload it from the [releases page](https://github.com/XFox111/TabsAsideExtension/releases). Use outdated versions at your own risk.
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
You can report a security issue by clicking "Report a vulnerability" button at the top-right of this page, or by going through [this link](https://github.com/XFox111/TabsAsideExtension/security/advisories/new)
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<style type="text/css">
|
||||||
|
.icon {
|
||||||
|
fill: #242424;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.icon {
|
||||||
|
fill: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="icon"
|
||||||
|
d="M10 18C14.4183 18 18 14.4183 18 10C18 5.58172 14.4183 2 10 2C5.58172 2 2 5.58172 2 10C2 14.4183 5.58172 18 10 18ZM10 3C10.6568 3 11.4068 3.59025 12.0218 4.90814C12.2393 5.37419 12.4283 5.90978 12.5806 6.5H7.41936C7.57172 5.90978 7.76073 5.37419 7.97822 4.90814C8.59323 3.59025 9.34315 3 10 3ZM7.07203 4.48526C6.79564 5.07753 6.56498 5.75696 6.38931 6.5H3.93648C4.77295 5.05399 6.11182 3.93497 7.71442 3.38163C7.47297 3.71222 7.25828 4.08617 7.07203 4.48526ZM6.19265 7.5C6.06723 8.28832 6 9.12934 6 10C6 10.8707 6.06723 11.7117 6.19265 12.5H3.45963C3.16268 11.7236 3 10.8808 3 10C3 9.1192 3.16268 8.2764 3.45963 7.5H6.19265ZM6.38931 13.5C6.56498 14.243 6.79564 14.9225 7.07203 15.5147C7.25828 15.9138 7.47297 16.2878 7.71442 16.6184C6.11182 16.065 4.77295 14.946 3.93648 13.5H6.38931ZM7.41936 13.5H12.5806C12.4283 14.0902 12.2393 14.6258 12.0218 15.0919C11.4068 16.4097 10.6568 17 10 17C9.34315 17 8.59323 16.4097 7.97822 15.0919C7.76073 14.6258 7.57172 14.0902 7.41936 13.5ZM12.7938 12.5H7.20617C7.07345 11.7253 7 10.8833 7 10C7 9.11669 7.07345 8.27472 7.20617 7.5H12.7938C12.9266 8.27472 13 9.11669 13 10C13 10.8833 12.9266 11.7253 12.7938 12.5ZM13.6107 13.5H16.0635C15.2271 14.946 13.8882 16.065 12.2856 16.6184C12.527 16.2878 12.7417 15.9138 12.928 15.5147C13.2044 14.9225 13.435 14.243 13.6107 13.5ZM16.5404 12.5H13.8074C13.9328 11.7117 14 10.8707 14 10C14 9.12934 13.9328 8.28832 13.8074 7.5H16.5404C16.8373 8.2764 17 9.1192 17 10C17 10.8808 16.8373 11.7236 16.5404 12.5ZM12.2856 3.38163C13.8882 3.93497 15.2271 5.05399 16.0635 6.5H13.6107C13.435 5.75696 13.2044 5.07753 12.928 4.48526C12.7417 4.08617 12.527 3.71222 12.2856 3.38163Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE svg SYSTEM "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-flat-20110816.dtd">
|
||||||
|
<svg id="PagePlaceholder" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600">
|
||||||
|
<defs>
|
||||||
|
<style type="text/css">
|
||||||
|
.background {
|
||||||
|
fill: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageElements {
|
||||||
|
fill: #c2c2c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.background {
|
||||||
|
fill: #141414;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageElements {
|
||||||
|
fill: #666666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect class="background" width="800" height="600" rx="10" ry="10" />
|
||||||
|
<g class="pageElements">
|
||||||
|
<rect x="50" y="50" width="700" height="300" rx="10" ry="10" />
|
||||||
|
<rect x="50" y="370" width="220" height="180" rx="10" ry="10" />
|
||||||
|
<rect x="530" y="370" width="220" height="180" rx="10" ry="10" />
|
||||||
|
<rect x="290" y="370" width="220" height="180" rx="10" ry="10" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 899 B |
@@ -0,0 +1,54 @@
|
|||||||
|
html,
|
||||||
|
body
|
||||||
|
{
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
-moz-user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
*
|
||||||
|
{
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root > .fui-FluentProvider
|
||||||
|
{
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* width */
|
||||||
|
::-webkit-scrollbar
|
||||||
|
{
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Track */
|
||||||
|
::-webkit-scrollbar-track
|
||||||
|
{
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle */
|
||||||
|
::-webkit-scrollbar-thumb
|
||||||
|
{
|
||||||
|
background-color: var(--colorNeutralStroke1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover
|
||||||
|
{
|
||||||
|
background-color: var(--colorNeutralStroke1Hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover:active
|
||||||
|
{
|
||||||
|
background-color: var(--colorNeutralStroke1Pressed);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useDangerStyles } from "@/hooks/useDangerStyles";
|
||||||
|
import { Button, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, DialogTrigger } from "@fluentui/react-components";
|
||||||
|
|
||||||
|
export default function PromptDialog(props: PromptDialogProps): React.ReactElement
|
||||||
|
{
|
||||||
|
const dangerCls = useDangerStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogSurface>
|
||||||
|
<DialogBody>
|
||||||
|
|
||||||
|
<DialogTitle>{ props.title }</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
{ props.content }
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
<DialogTrigger disableButtonEnhancement>
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
className={ props.destructive ? dangerCls.buttonPrimary : undefined }
|
||||||
|
onClick={ props.onConfirm }
|
||||||
|
>
|
||||||
|
{ props.confirmText }
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogTrigger disableButtonEnhancement>
|
||||||
|
<Button appearance="subtle">
|
||||||
|
{ props.cancelText ?? i18n.t("common.actions.cancel") }
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</DialogActions>
|
||||||
|
|
||||||
|
</DialogBody>
|
||||||
|
</DialogSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptDialogProps =
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
content: React.ReactNode;
|
||||||
|
confirmText: string;
|
||||||
|
cancelText?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
destructive?: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Dialog, DialogModalType } from "@fluentui/react-components";
|
||||||
|
import { createContext, PropsWithChildren, ReactElement } from "react";
|
||||||
|
import PromptDialog, { PromptDialogProps } from "@/components/PromptDialog";
|
||||||
|
|
||||||
|
const DialogContext = createContext<DialogContextType>(null!);
|
||||||
|
|
||||||
|
export default function DialogProvider(props: PropsWithChildren): ReactElement
|
||||||
|
{
|
||||||
|
const [dialog, setDialog] = useState<ReactElement | null>(null);
|
||||||
|
const [modalType, setModalType] = useState<DialogModalType | undefined>(undefined);
|
||||||
|
const [onDismiss, setOnDismiss] = useState<(() => void) | undefined>(undefined);
|
||||||
|
|
||||||
|
const pushPrompt = (props: PromptDialogProps): void =>
|
||||||
|
setDialog(
|
||||||
|
<PromptDialog { ...props } />
|
||||||
|
);
|
||||||
|
|
||||||
|
const pushCustom = (dialogSurface: ReactElement, modalType?: DialogModalType, onDismiss?: () => void): void =>
|
||||||
|
{
|
||||||
|
setDialog(dialogSurface);
|
||||||
|
setModalType(modalType);
|
||||||
|
setOnDismiss(() => onDismiss);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = () =>
|
||||||
|
{
|
||||||
|
onDismiss?.();
|
||||||
|
setOnDismiss(undefined);
|
||||||
|
setTimeout(() => setDialog(null), 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContext.Provider value={ { pushPrompt, pushCustom } }>
|
||||||
|
{ props.children }
|
||||||
|
|
||||||
|
{ dialog &&
|
||||||
|
<Dialog defaultOpen onOpenChange={ handleOpenChange } modalType={ modalType }>
|
||||||
|
{ dialog }
|
||||||
|
</Dialog>
|
||||||
|
}
|
||||||
|
</DialogContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDialog = () => useContext<DialogContextType>(DialogContext);
|
||||||
|
|
||||||
|
export type DialogContextType =
|
||||||
|
{
|
||||||
|
pushPrompt(props: PromptDialogProps): void;
|
||||||
|
pushCustom(dialogSurface: ReactElement, modalType?: DialogModalType, onDismiss?: () => void): void;
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { FluentProvider, Theme, webDarkTheme, webLightTheme } from "@fluentui/react-components";
|
||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType>({ theme: webLightTheme, isDark: false });
|
||||||
|
const media: MediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
|
||||||
|
export default function ThemeProvider(props: React.PropsWithChildren): React.ReactElement
|
||||||
|
{
|
||||||
|
const [isDark, setIsDark] = useState<boolean>(media.matches);
|
||||||
|
const theme = useMemo(() => isDark ? webDarkTheme : webLightTheme, [isDark]);
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
const updateTheme = (args: MediaQueryListEvent) => setIsDark(args.matches);
|
||||||
|
media.addEventListener("change", updateTheme);
|
||||||
|
|
||||||
|
return () => media.removeEventListener("change", updateTheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={ { theme, isDark } }>
|
||||||
|
<FluentProvider theme={ theme }>
|
||||||
|
{ props.children }
|
||||||
|
</FluentProvider>
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTheme = (): ThemeContextType => useContext(ThemeContext);
|
||||||
|
|
||||||
|
export type ThemeContextType =
|
||||||
|
{
|
||||||
|
theme: Theme;
|
||||||
|
isDark: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Package from "@/package.json";
|
||||||
|
|
||||||
|
export const buyMeACoffeeLink: string = "https://buymeacoffee.com/xfox111";
|
||||||
|
export const bskyLink: string = "https://bsky.app/profile/xfox111.net";
|
||||||
|
export const websiteLink: string = "https://xfox111.net";
|
||||||
|
export const v3blogPost: string = "https://at.xfox111.net/tabs-aside-3-0";
|
||||||
|
|
||||||
|
const githubLink = (path: string = "."): string =>
|
||||||
|
new URL(path, browser.runtime.getManifest().homepage_url).href;
|
||||||
|
|
||||||
|
export const githubLinks =
|
||||||
|
{
|
||||||
|
repo: githubLink(),
|
||||||
|
release: githubLink(`releases/tag/v${Package.version}`),
|
||||||
|
license: githubLink("blob/main/LICENSE"),
|
||||||
|
translationGuide: githubLink("wiki/Contribution-Guidelines#contributing-to-translations")
|
||||||
|
};
|
||||||
|
|
||||||
|
export const storeLink: string =
|
||||||
|
import.meta.env.FIREFOX
|
||||||
|
? "https://addons.mozilla.org/en-US/firefox/addon/ms-edge-tabs-aside/" :
|
||||||
|
chrome.runtime.getManifest().update_url?.startsWith("https://edge.microsoft.com/") ?
|
||||||
|
"https://microsoftedge.microsoft.com/addons/detail/tabs-aside/kmnblllmalkiapkfknnlpobmjjdnlhnd" :
|
||||||
|
"https://chrome.google.com/webstore/detail/mgmjbodjgijnebfgohlnjkegdbdjgin";
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { collectionCount, getCollections, saveCollections } from "@/features/collectionStorage";
|
||||||
|
import { migrateStorage } from "@/features/migration";
|
||||||
|
import { showWelcomeDialog } from "@/features/v3welcome/utils/showWelcomeDialog";
|
||||||
|
import { SettingsValue } from "@/hooks/useSettings";
|
||||||
|
import { CollectionItem, GraphicsStorage } from "@/models/CollectionModels";
|
||||||
|
import getLogger from "@/utils/getLogger";
|
||||||
|
import { onMessage, sendMessage } from "@/utils/messaging";
|
||||||
|
import saveTabsToCollection from "@/utils/saveTabsToCollection";
|
||||||
|
import sendNotification from "@/utils/sendNotification";
|
||||||
|
import { settings } from "@/utils/settings";
|
||||||
|
import watchTabSelection from "@/utils/watchTabSelection";
|
||||||
|
import { Tabs, Windows } from "wxt/browser";
|
||||||
|
import { Unwatch } from "wxt/storage";
|
||||||
|
|
||||||
|
export default defineBackground(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const logger = getLogger("background");
|
||||||
|
const graphicsCache: GraphicsStorage = {};
|
||||||
|
let listLocation: SettingsValue<"listLocation"> = "sidebar";
|
||||||
|
|
||||||
|
logger("Background script started");
|
||||||
|
|
||||||
|
// Little workaround for opening side panel
|
||||||
|
// See: https://stackoverflow.com/questions/77213045/error-sidepanel-open-may-only-be-called-in-response-to-a-user-gesture-re
|
||||||
|
settings.listLocation.getValue().then(location => listLocation = location);
|
||||||
|
settings.listLocation.watch(newLocation => listLocation = newLocation);
|
||||||
|
|
||||||
|
browser.runtime.onInstalled.addListener(async ({ reason, previousVersion }) =>
|
||||||
|
{
|
||||||
|
logger("onInstalled", reason, previousVersion);
|
||||||
|
|
||||||
|
const previousMajor: number = previousVersion ? parseInt(previousVersion.split(".")[0]) : 0;
|
||||||
|
|
||||||
|
if (reason === "update" && previousMajor < 3)
|
||||||
|
{
|
||||||
|
await migrateStorage();
|
||||||
|
await showWelcomeDialog.setValue(true);
|
||||||
|
browser.runtime.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.tabs.onUpdated.addListener((_, __, tab) =>
|
||||||
|
{
|
||||||
|
if (!tab.url)
|
||||||
|
return;
|
||||||
|
|
||||||
|
graphicsCache[tab.url] = {
|
||||||
|
preview: graphicsCache[tab.url]?.preview,
|
||||||
|
icon: tab.favIconUrl ?? graphicsCache[tab.url]?.icon
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.commands.onCommand.addListener(
|
||||||
|
(command, tab) => performContextAction(command, tab!.windowId!)
|
||||||
|
);
|
||||||
|
|
||||||
|
onMessage("getGraphicsCache", () => graphicsCache);
|
||||||
|
onMessage("addThumbnail", ({ data }) =>
|
||||||
|
{
|
||||||
|
graphicsCache[data.url] = {
|
||||||
|
preview: data.thumbnail,
|
||||||
|
icon: graphicsCache[data.url]?.icon
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setupContextMenu();
|
||||||
|
async function setupContextMenu(): Promise<void>
|
||||||
|
{
|
||||||
|
await browser.contextMenus.removeAll();
|
||||||
|
|
||||||
|
const items: Record<string, string> =
|
||||||
|
{
|
||||||
|
"show_collections": i18n.t("actions.show_collections"),
|
||||||
|
"set_aside": i18n.t("actions.set_aside.all"),
|
||||||
|
"save": i18n.t("actions.save.all")
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(items).forEach(([id, title]) => browser.contextMenus.create({
|
||||||
|
id, title,
|
||||||
|
visible: true,
|
||||||
|
contexts: ["action", "page"]
|
||||||
|
}));
|
||||||
|
|
||||||
|
watchTabSelection(async selection =>
|
||||||
|
{
|
||||||
|
await browser.contextMenus.update("set_aside", {
|
||||||
|
title: i18n.t(`actions.set_aside.${selection}`)
|
||||||
|
});
|
||||||
|
await browser.contextMenus.update("save", {
|
||||||
|
title: i18n.t(`actions.save.${selection}`)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.contextMenus.onClicked.addListener(
|
||||||
|
({ menuItemId }, tab) => performContextAction((menuItemId as string), tab!.windowId!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupBadge();
|
||||||
|
async function setupBadge(): Promise<void>
|
||||||
|
{
|
||||||
|
let unwatchBadge: Unwatch | null = null;
|
||||||
|
const updateBadge = async (count: number | null) =>
|
||||||
|
await browser.action.setBadgeText({ text: count && count > 0 ? count.toString() : "" });
|
||||||
|
|
||||||
|
if (await settings.showBadge.getValue())
|
||||||
|
{
|
||||||
|
updateBadge(await collectionCount.getValue());
|
||||||
|
unwatchBadge = collectionCount.watch(updateBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.FIREFOX)
|
||||||
|
{
|
||||||
|
await browser.action.setBadgeBackgroundColor({ color: "0f6cbd" });
|
||||||
|
await browser.action.setBadgeTextColor({ color: "white" });
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.showBadge.watch(async showBadge =>
|
||||||
|
{
|
||||||
|
if (showBadge)
|
||||||
|
{
|
||||||
|
updateBadge(await collectionCount.getValue());
|
||||||
|
unwatchBadge = collectionCount.watch(updateBadge);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
unwatchBadge?.();
|
||||||
|
await browser.action.setBadgeText({ text: "" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupActionButton();
|
||||||
|
async function setupActionButton(): Promise<void>
|
||||||
|
{
|
||||||
|
let unwatchActionTitle: Unwatch | null = null;
|
||||||
|
|
||||||
|
const onClickAction = async (): Promise<void> =>
|
||||||
|
{
|
||||||
|
logger("action.onClicked");
|
||||||
|
const defaultAction = await settings.defaultSaveAction.getValue();
|
||||||
|
await saveTabs(defaultAction === "set_aside");
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTitle = async (selection: "all" | "selected"): Promise<void> =>
|
||||||
|
{
|
||||||
|
const defaultAction = await settings.defaultSaveAction.getValue();
|
||||||
|
await browser.action.setTitle({ title: i18n.t(`actions.${defaultAction}.${selection}`) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateButton = async (action: SettingsValue<"contextAction">): Promise<void> =>
|
||||||
|
{
|
||||||
|
logger("updateButton", action);
|
||||||
|
|
||||||
|
// Cleanup any existing behavior
|
||||||
|
browser.action.onClicked.removeListener(onClickAction);
|
||||||
|
browser.action.onClicked.removeListener(browser?.sidebarAction?.toggle);
|
||||||
|
browser.action.onClicked.removeListener(openCollectionsInTab);
|
||||||
|
|
||||||
|
await browser.action.disable();
|
||||||
|
await browser.action.setTitle({ title: i18n.t("manifest.name") });
|
||||||
|
unwatchActionTitle?.();
|
||||||
|
|
||||||
|
if (!import.meta.env.FIREFOX)
|
||||||
|
await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false });
|
||||||
|
|
||||||
|
// Setup new behavior
|
||||||
|
if (action === "action")
|
||||||
|
{
|
||||||
|
browser.action.onClicked.addListener(onClickAction);
|
||||||
|
unwatchActionTitle = watchTabSelection(updateTitle);
|
||||||
|
await browser.action.enable();
|
||||||
|
}
|
||||||
|
else if (action === "open")
|
||||||
|
{
|
||||||
|
await browser.action.enable();
|
||||||
|
const location = await settings.listLocation.getValue();
|
||||||
|
|
||||||
|
if (location === "sidebar")
|
||||||
|
{
|
||||||
|
if (import.meta.env.FIREFOX)
|
||||||
|
browser.action.onClicked.addListener(browser.sidebarAction.toggle);
|
||||||
|
else
|
||||||
|
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
|
||||||
|
}
|
||||||
|
else if (location !== "popup")
|
||||||
|
browser.action.onClicked.addListener(openCollectionsInTab);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateButton(await settings.contextAction.getValue());
|
||||||
|
settings.contextAction.watch(updateButton);
|
||||||
|
settings.listLocation.watch(async () => updateButton(await settings.contextAction.getValue()));
|
||||||
|
}
|
||||||
|
|
||||||
|
setupCollectionView();
|
||||||
|
async function setupCollectionView(): Promise<void>
|
||||||
|
{
|
||||||
|
const enforcePinnedTab = async (info: Tabs.OnHighlightedHighlightInfoType): Promise<void> =>
|
||||||
|
{
|
||||||
|
logger("enforcePinnedTab", info);
|
||||||
|
|
||||||
|
const activeWindow: Windows.Window = await browser.windows.getCurrent({ populate: true });
|
||||||
|
|
||||||
|
if (activeWindow.incognito)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!activeWindow.tabs!.some(tab =>
|
||||||
|
[tab.url, tab.pendingUrl].includes(browser.runtime.getURL("/sidepanel.html")))
|
||||||
|
)
|
||||||
|
await browser.tabs.create({
|
||||||
|
url: browser.runtime.getURL("/sidepanel.html"),
|
||||||
|
windowId: activeWindow.id,
|
||||||
|
active: false,
|
||||||
|
pinned: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateView = async (viewLocation: SettingsValue<"listLocation">): Promise<void> =>
|
||||||
|
{
|
||||||
|
logger("updateView", viewLocation);
|
||||||
|
|
||||||
|
browser.tabs.onHighlighted.removeListener(enforcePinnedTab);
|
||||||
|
const tabs: Tabs.Tab[] = await browser.tabs.query({
|
||||||
|
currentWindow: true,
|
||||||
|
url: browser.runtime.getURL("/sidepanel.html")
|
||||||
|
});
|
||||||
|
await browser.tabs.remove(tabs.map(tab => tab.id!));
|
||||||
|
|
||||||
|
await browser.action.setPopup({
|
||||||
|
popup: viewLocation === "popup" ? browser.runtime.getURL("/popup.html") : ""
|
||||||
|
});
|
||||||
|
|
||||||
|
if (import.meta.env.FIREFOX)
|
||||||
|
await browser.sidebarAction.setPanel({
|
||||||
|
panel: viewLocation === "sidebar" ? browser.runtime.getURL("/sidepanel.html") : ""
|
||||||
|
});
|
||||||
|
else
|
||||||
|
await chrome.sidePanel.setOptions({ enabled: viewLocation === "sidebar" });
|
||||||
|
|
||||||
|
if (viewLocation === "pinned")
|
||||||
|
{
|
||||||
|
await browser.tabs.create({
|
||||||
|
url: browser.runtime.getURL("/sidepanel.html"),
|
||||||
|
active: false,
|
||||||
|
pinned: true
|
||||||
|
});
|
||||||
|
browser.tabs.onHighlighted.addListener(enforcePinnedTab);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateView(await settings.listLocation.getValue());
|
||||||
|
settings.listLocation.watch(updateView);
|
||||||
|
}
|
||||||
|
|
||||||
|
function performContextAction(action: string, windowId: number): void
|
||||||
|
{
|
||||||
|
if (action === "show_collections")
|
||||||
|
{
|
||||||
|
if (listLocation === "sidebar" || listLocation === "popup")
|
||||||
|
openCollectionsInView(listLocation, windowId);
|
||||||
|
else
|
||||||
|
openCollectionsInTab();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
saveTabs(action === "set_aside");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCollectionsInView(view: "sidebar" | "popup", windowId: number): void
|
||||||
|
{
|
||||||
|
if (view === "sidebar")
|
||||||
|
{
|
||||||
|
if (import.meta.env.FIREFOX)
|
||||||
|
browser.sidebarAction.open();
|
||||||
|
else
|
||||||
|
chrome.sidePanel.open({ windowId });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
browser.action.openPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCollectionsInTab(): Promise<void>
|
||||||
|
{
|
||||||
|
logger("openCollectionsInTab");
|
||||||
|
|
||||||
|
const currentWindow: Windows.Window = await browser.windows.getCurrent({ populate: true });
|
||||||
|
|
||||||
|
if (currentWindow.incognito)
|
||||||
|
{
|
||||||
|
await browser.windows.create({
|
||||||
|
url: browser.runtime.getURL("/sidepanel.html"),
|
||||||
|
focused: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const collectionTab: Tabs.Tab | undefined = currentWindow.tabs!.find(
|
||||||
|
tab => tab.url === browser.runtime.getURL("/sidepanel.html")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (collectionTab)
|
||||||
|
await browser.tabs.update(collectionTab.id, { active: true });
|
||||||
|
else
|
||||||
|
await browser.tabs.create({
|
||||||
|
url: browser.runtime.getURL("/sidepanel.html"),
|
||||||
|
active: true,
|
||||||
|
windowId: currentWindow.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTabs(closeAfterSave: boolean): Promise<void>
|
||||||
|
{
|
||||||
|
logger("saveTabs", closeAfterSave);
|
||||||
|
|
||||||
|
const collection: CollectionItem = await saveTabsToCollection(closeAfterSave);
|
||||||
|
const [savedCollections, cloudIssue] = await getCollections();
|
||||||
|
const newList = [collection, ...savedCollections];
|
||||||
|
|
||||||
|
await saveCollections(newList, cloudIssue === null, graphicsCache);
|
||||||
|
|
||||||
|
sendMessage("refreshCollections", undefined);
|
||||||
|
|
||||||
|
if (await settings.notifyOnSave.getValue())
|
||||||
|
await sendNotification({
|
||||||
|
title: i18n.t("notifications.tabs_saved.title"),
|
||||||
|
message: i18n.t("notifications.tabs_saved.message"),
|
||||||
|
icon: "/notification_icons/cloud_checkmark.png"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ex)
|
||||||
|
{
|
||||||
|
console.error(ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import getLogger from "@/utils/getLogger";
|
||||||
|
import { sendMessage } from "@/utils/messaging";
|
||||||
|
|
||||||
|
// This content script is injected into each browser tab.
|
||||||
|
// It's purpose is to retrive an OpenGraph thumbnail URL from the metadata
|
||||||
|
|
||||||
|
export default defineContentScript({
|
||||||
|
matches: ["<all_urls>"],
|
||||||
|
runAt: "document_idle",
|
||||||
|
main
|
||||||
|
});
|
||||||
|
|
||||||
|
const logger = getLogger("contentScript");
|
||||||
|
|
||||||
|
async function main(): Promise<void>
|
||||||
|
{
|
||||||
|
logger("init");
|
||||||
|
|
||||||
|
// This method tries to sequentially retrieve thumbnails from all know meta tags.
|
||||||
|
// It stops on the first thumbnail found.
|
||||||
|
|
||||||
|
// The order of search is:
|
||||||
|
// 1. <meta property="og:image" content="https://example.com/image.jpg">
|
||||||
|
// 2. <meta name="twitter:image" content="https://example.com/image.jpg">
|
||||||
|
// 3. <link rel="thumbnail" href="https://example.com/thumbnail.jpg">
|
||||||
|
// 4. <link rel="image_src" href="https://example.com/image.jpg">
|
||||||
|
|
||||||
|
const thumbnailUrl: string | undefined =
|
||||||
|
document.querySelector<HTMLMetaElement>("head meta[property='og:image']")?.content ??
|
||||||
|
document.querySelector<HTMLMetaElement>("head meta[name='twitter:image']")?.content ??
|
||||||
|
document.querySelector<HTMLLinkElement>("head link[rel=thumbnail]")?.href ??
|
||||||
|
document.querySelector<HTMLLinkElement>("head link[rel=image_src]")?.href;
|
||||||
|
|
||||||
|
if (thumbnailUrl)
|
||||||
|
{
|
||||||
|
logger(`Found thumbnail for "${document.location.href}"`, thumbnailUrl);
|
||||||
|
await sendMessage("addThumbnail", {
|
||||||
|
url: document.location.href,
|
||||||
|
thumbnail: thumbnailUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
logger(`No thumbnail found for "${document.location.href}"`);
|
||||||
|
|
||||||
|
logger("done");
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||||
|
|
||||||
|
export const useOptionsStyles = makeStyles({
|
||||||
|
main:
|
||||||
|
{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateRows: "auto 1fr",
|
||||||
|
height: "100%"
|
||||||
|
},
|
||||||
|
tabList:
|
||||||
|
{
|
||||||
|
flexWrap: "wrap"
|
||||||
|
},
|
||||||
|
article:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
flexFlow: "column",
|
||||||
|
gap: tokens.spacingVerticalMNudge,
|
||||||
|
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
|
||||||
|
overflowY: "auto"
|
||||||
|
},
|
||||||
|
section:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
flexFlow: "column",
|
||||||
|
alignItems: "flex-start"
|
||||||
|
},
|
||||||
|
buttonFix:
|
||||||
|
{
|
||||||
|
minHeight: "32px"
|
||||||
|
},
|
||||||
|
horizontalButtons:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: tokens.spacingHorizontalS
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Tabs aside | Settings</title>
|
||||||
|
|
||||||
|
<meta name="manifest.open_in_tab" content="false" />
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
body
|
||||||
|
{
|
||||||
|
height: 500px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { BuyMeACoffee20Regular } from "@/assets/BuyMeACoffee20";
|
||||||
|
import { bskyLink, buyMeACoffeeLink, githubLinks, storeLink, websiteLink } from "@/data/links";
|
||||||
|
import { useBmcStyles } from "@/hooks/useBmcStyles";
|
||||||
|
import extLink from "@/utils/extLink";
|
||||||
|
import { Body1, Button, Caption1, Link, Subtitle1, Text } from "@fluentui/react-components";
|
||||||
|
import { PersonFeedback20Regular } from "@fluentui/react-icons";
|
||||||
|
import { useOptionsStyles } from "../hooks/useOptionsStyles";
|
||||||
|
import Package from "@/package.json";
|
||||||
|
|
||||||
|
export default function AboutSection(): React.ReactElement
|
||||||
|
{
|
||||||
|
const cls = useOptionsStyles();
|
||||||
|
const bmcCls = useBmcStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text as="p">
|
||||||
|
<Subtitle1>{ i18n.t("manifest.name") }</Subtitle1>
|
||||||
|
<sup><Caption1> v{ Package.version }</Caption1></sup>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Body1 as="p">
|
||||||
|
{ i18n.t("options_page.about.developed_by") } (<Link { ...extLink(bskyLink) }>@xfox111.net</Link>)<br />
|
||||||
|
{ i18n.t("options_page.about.licensed_under") } <Link { ...extLink(githubLinks.license) }>{ i18n.t("options_page.about.mit_license") }</Link>
|
||||||
|
</Body1>
|
||||||
|
|
||||||
|
<Body1 as="p">
|
||||||
|
{ i18n.t("options_page.about.translation_cta.text") }<br />
|
||||||
|
<Link { ...extLink(githubLinks.translationGuide) }>
|
||||||
|
{ i18n.t("options_page.about.translation_cta.button") }
|
||||||
|
</Link>
|
||||||
|
</Body1>
|
||||||
|
|
||||||
|
<Body1 as="p">
|
||||||
|
<Link { ...extLink(websiteLink) }>{ i18n.t("options_page.about.links.website") }</Link><br />
|
||||||
|
<Link { ...extLink(githubLinks.repo) }>{ i18n.t("options_page.about.links.source") }</Link><br />
|
||||||
|
<Link { ...extLink(githubLinks.release) }>{ i18n.t("options_page.about.links.changelog") }</Link>
|
||||||
|
</Body1>
|
||||||
|
|
||||||
|
<div className={ cls.horizontalButtons }>
|
||||||
|
<Button
|
||||||
|
as="a" { ...extLink(storeLink) }
|
||||||
|
appearance="primary"
|
||||||
|
icon={ <PersonFeedback20Regular /> }
|
||||||
|
>
|
||||||
|
{ i18n.t("common.cta.feedback") }
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
as="a" { ...extLink(buyMeACoffeeLink) }
|
||||||
|
appearance="primary" className={ bmcCls.button }
|
||||||
|
icon={ <BuyMeACoffee20Regular /> }
|
||||||
|
>
|
||||||
|
{ i18n.t("common.cta.sponsor") }
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import useSettings, { SettingsValue } from "@/hooks/useSettings";
|
||||||
|
import { Dropdown, Field, Option } from "@fluentui/react-components";
|
||||||
|
|
||||||
|
export default function ActionsSection(): React.ReactElement
|
||||||
|
{
|
||||||
|
const [saveAction, setSaveAction] = useSettings("defaultSaveAction");
|
||||||
|
const [restoreAction, setRestoreAction] = useSettings("defaultRestoreAction");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field label={ i18n.t("options_page.actions.options.save_actions.title") }>
|
||||||
|
<Dropdown
|
||||||
|
value={ saveAction ? saveActionOptions[saveAction] : "" }
|
||||||
|
selectedOptions={ [saveAction ?? ""] }
|
||||||
|
onOptionSelect={ (_, e) => setSaveAction(e.optionValue as SaveActionType) }
|
||||||
|
>
|
||||||
|
{ Object.entries(saveActionOptions).map(([value, label]) =>
|
||||||
|
<Option key={ value } value={ value }>
|
||||||
|
{ label }
|
||||||
|
</Option>
|
||||||
|
) }
|
||||||
|
</Dropdown>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={ i18n.t("options_page.actions.options.restore_actions.title") }>
|
||||||
|
<Dropdown
|
||||||
|
value={ restoreAction ? restoreActionOptions[restoreAction] : "" }
|
||||||
|
selectedOptions={ [restoreAction ?? ""] }
|
||||||
|
onOptionSelect={ (_, e) => setRestoreAction(e.optionValue as RestoreActionType) }
|
||||||
|
>
|
||||||
|
{ Object.entries(restoreActionOptions).map(([value, label]) =>
|
||||||
|
<Option key={ value } value={ value }>
|
||||||
|
{ label }
|
||||||
|
</Option>
|
||||||
|
) }
|
||||||
|
</Dropdown>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveActionType = SettingsValue<"defaultSaveAction">;
|
||||||
|
type RestoreActionType = SettingsValue<"defaultRestoreAction">;
|
||||||
|
|
||||||
|
const restoreActionOptions: Record<RestoreActionType, string> =
|
||||||
|
{
|
||||||
|
"open": i18n.t("options_page.actions.options.restore_actions.options.open"),
|
||||||
|
"restore": i18n.t("options_page.actions.options.restore_actions.options.restore")
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveActionOptions: Record<SaveActionType, string> =
|
||||||
|
{
|
||||||
|
"set_aside": i18n.t("options_page.actions.options.save_actions.options.set_aside"),
|
||||||
|
"save": i18n.t("options_page.actions.options.save_actions.options.save")
|
||||||
|
};
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import useSettings, { SettingsValue } from "@/hooks/useSettings";
|
||||||
|
import { Button, Checkbox, Dropdown, Field, Option, OptionOnSelectData } from "@fluentui/react-components";
|
||||||
|
import { KeyCommand20Regular } from "@fluentui/react-icons";
|
||||||
|
import { useOptionsStyles } from "../hooks/useOptionsStyles";
|
||||||
|
|
||||||
|
export default function GeneralSection(): React.ReactElement
|
||||||
|
{
|
||||||
|
const [alwaysShowToolbars, setAlwaysShowToolbars] = useSettings("alwaysShowToolbars");
|
||||||
|
const [ignorePinned, setIgnorePinned] = useSettings("ignorePinned");
|
||||||
|
const [deletePrompt, setDeletePrompt] = useSettings("deletePrompt");
|
||||||
|
const [showBadge, setShowBadge] = useSettings("showBadge");
|
||||||
|
const [notifyOnSave, setNotifyOnSave] = useSettings("notifyOnSave");
|
||||||
|
const [dismissOnLoad, setDismissOnLoad] = useSettings("dismissOnLoad");
|
||||||
|
const [listLocation, setListLocation] = useSettings("listLocation");
|
||||||
|
const [contextAction, setContextAction] = useSettings("contextAction");
|
||||||
|
|
||||||
|
const cls = useOptionsStyles();
|
||||||
|
|
||||||
|
const openShortcutsPage = (): Promise<any> =>
|
||||||
|
browser.tabs.create({
|
||||||
|
url: "chrome://extensions/shortcuts",
|
||||||
|
active: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleListLocationChange = (_: any, e: OptionOnSelectData): void =>
|
||||||
|
{
|
||||||
|
if (e.optionValue === "popup" && contextAction !== "open")
|
||||||
|
setContextAction("open");
|
||||||
|
|
||||||
|
setListLocation(e.optionValue as ListLocationType);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className={ cls.section }>
|
||||||
|
<Checkbox
|
||||||
|
label={ i18n.t("options_page.general.options.always_show_toolbars") }
|
||||||
|
checked={ alwaysShowToolbars ?? false }
|
||||||
|
onChange={ (_, e) => setAlwaysShowToolbars(e.checked as boolean) } />
|
||||||
|
<Checkbox
|
||||||
|
label={ i18n.t("options_page.general.options.include_pinned") }
|
||||||
|
checked={ !ignorePinned }
|
||||||
|
onChange={ (_, e) => setIgnorePinned(!e.checked) } />
|
||||||
|
<Checkbox
|
||||||
|
label={ i18n.t("options_page.general.options.show_delete_prompt") }
|
||||||
|
checked={ deletePrompt ?? false }
|
||||||
|
onChange={ (_, e) => setDeletePrompt(e.checked as boolean) } />
|
||||||
|
<Checkbox
|
||||||
|
label={ i18n.t("options_page.general.options.show_badge") }
|
||||||
|
checked={ showBadge ?? false }
|
||||||
|
onChange={ (_, e) => setShowBadge(e.checked as boolean) } />
|
||||||
|
<Checkbox
|
||||||
|
label={ i18n.t("options_page.general.options.show_notification") }
|
||||||
|
checked={ notifyOnSave ?? false }
|
||||||
|
onChange={ (_, e) => setNotifyOnSave(e.checked as boolean) } />
|
||||||
|
<Checkbox
|
||||||
|
label={ i18n.t("options_page.general.options.unload_tabs") }
|
||||||
|
checked={ dismissOnLoad ?? false }
|
||||||
|
onChange={ (_, e) => setDismissOnLoad(e.checked as boolean) } />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Field label={ i18n.t("options_page.general.options.list_locations.title") }>
|
||||||
|
<Dropdown
|
||||||
|
value={ listLocation ? listLocationOptions[listLocation] : "" }
|
||||||
|
selectedOptions={ [listLocation ?? ""] }
|
||||||
|
onOptionSelect={ handleListLocationChange }
|
||||||
|
>
|
||||||
|
{ Object.entries(listLocationOptions).map(([key, value]) =>
|
||||||
|
<Option key={ key } value={ key }>
|
||||||
|
{ value }
|
||||||
|
</Option>
|
||||||
|
) }
|
||||||
|
</Dropdown>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={ i18n.t("options_page.general.options.icon_action.title") }>
|
||||||
|
<Dropdown
|
||||||
|
value={ contextAction ? contextActionOptions[contextAction] : "" }
|
||||||
|
selectedOptions={ [contextAction ?? ""] }
|
||||||
|
onOptionSelect={ (_, e) => setContextAction(e.optionValue as ContextActionType) }
|
||||||
|
disabled={ listLocation === "popup" }
|
||||||
|
>
|
||||||
|
{ Object.entries(contextActionOptions).map(([key, value]) =>
|
||||||
|
key === "context" && import.meta.env.FIREFOX
|
||||||
|
? <></> :
|
||||||
|
<Option key={ key } value={ key }>
|
||||||
|
{ value }
|
||||||
|
</Option>
|
||||||
|
) }
|
||||||
|
</Dropdown>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{ !import.meta.env.FIREFOX &&
|
||||||
|
<Button icon={ <KeyCommand20Regular /> } onClick={ openShortcutsPage } className={ cls.buttonFix }>
|
||||||
|
{ i18n.t("options_page.general.options.change_shortcuts") }
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListLocationType = SettingsValue<"listLocation">;
|
||||||
|
type ContextActionType = SettingsValue<"contextAction">;
|
||||||
|
|
||||||
|
const listLocationOptions: Record<ListLocationType, string> =
|
||||||
|
{
|
||||||
|
"sidebar": i18n.t("options_page.general.options.list_locations.options.sidebar"),
|
||||||
|
"popup": i18n.t("options_page.general.options.list_locations.options.popup"),
|
||||||
|
"tab": i18n.t("options_page.general.options.list_locations.options.tab"),
|
||||||
|
"pinned": i18n.t("options_page.general.options.list_locations.options.pinned")
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextActionOptions: Record<ContextActionType, string> =
|
||||||
|
{
|
||||||
|
"action": i18n.t("options_page.general.options.icon_action.options.action"),
|
||||||
|
"context": i18n.t("options_page.general.options.icon_action.options.context"),
|
||||||
|
"open": i18n.t("options_page.general.options.icon_action.options.open")
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useDialog } from "@/contexts/DialogProvider";
|
||||||
|
import useStorageInfo from "@/hooks/useStorageInfo";
|
||||||
|
import { Button, Field, MessageBar, MessageBarBody, MessageBarTitle, ProgressBar } from "@fluentui/react-components";
|
||||||
|
import { ArrowDownload20Regular, ArrowUpload20Regular } from "@fluentui/react-icons";
|
||||||
|
import { useOptionsStyles } from "../hooks/useOptionsStyles";
|
||||||
|
import exportData from "../utils/exportData";
|
||||||
|
import importData from "../utils/importData";
|
||||||
|
|
||||||
|
export default function StorageSection(): React.ReactElement
|
||||||
|
{
|
||||||
|
const { bytesInUse, storageQuota, usedStorageRatio } = useStorageInfo();
|
||||||
|
const [importResult, setImportResult] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
const dialog = useDialog();
|
||||||
|
const cls = useOptionsStyles();
|
||||||
|
|
||||||
|
const handleImport = (): void =>
|
||||||
|
dialog.pushPrompt({
|
||||||
|
title: i18n.t("options_page.storage.import_prompt.title"),
|
||||||
|
confirmText: i18n.t("options_page.storage.import_prompt.proceed"),
|
||||||
|
onConfirm: () => importData().then(setImportResult),
|
||||||
|
content: (
|
||||||
|
<MessageBar intent="warning">
|
||||||
|
<MessageBarBody>
|
||||||
|
<MessageBarTitle>{ i18n.t("options_page.storage.import_prompt.warning_title") }</MessageBarTitle>
|
||||||
|
|
||||||
|
{ i18n.t("options_page.storage.import_prompt.warning_text") }
|
||||||
|
</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field
|
||||||
|
label={ i18n.t("options_page.storage.capacity.title") }
|
||||||
|
hint={ i18n.t("options_page.storage.capacity.description", [(bytesInUse / 1024).toFixed(1), storageQuota / 1024]) }
|
||||||
|
validationState={ usedStorageRatio >= 0.8 ? "error" : undefined }
|
||||||
|
>
|
||||||
|
<ProgressBar value={ usedStorageRatio } thickness="large" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className={ cls.horizontalButtons }>
|
||||||
|
<Button icon={ <ArrowDownload20Regular /> } onClick={ exportData }>
|
||||||
|
{ i18n.t("options_page.storage.export") }
|
||||||
|
</Button>
|
||||||
|
<Button icon={ <ArrowUpload20Regular /> } onClick={ handleImport }>
|
||||||
|
{ i18n.t("options_page.storage.import") }
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ importResult !== null &&
|
||||||
|
<MessageBar intent={ importResult ? "success" : "error" }>
|
||||||
|
<MessageBarBody>
|
||||||
|
{ importResult === true ?
|
||||||
|
i18n.t("options_page.storage.import_results.success") :
|
||||||
|
i18n.t("options_page.storage.import_results.error")
|
||||||
|
}
|
||||||
|
</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import App from "@/App.tsx";
|
||||||
|
import "@/assets/global.css";
|
||||||
|
import { Tab, TabList } from "@fluentui/react-components";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { useOptionsStyles } from "./hooks/useOptionsStyles.ts";
|
||||||
|
import AboutSection from "./layouts/AboutSection.tsx";
|
||||||
|
import ActionsSection from "./layouts/ActionsSection.tsx";
|
||||||
|
import GeneralSection from "./layouts/GeneralSection.tsx";
|
||||||
|
import StorageSection from "./layouts/StorageSection.tsx";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<App>
|
||||||
|
<OptionsPage />
|
||||||
|
</App>
|
||||||
|
);
|
||||||
|
|
||||||
|
function OptionsPage(): React.ReactElement
|
||||||
|
{
|
||||||
|
const [selection, setSelection] = useState<SelectionType>("general");
|
||||||
|
const cls = useOptionsStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className={ cls.main }>
|
||||||
|
<TabList
|
||||||
|
className={ cls.tabList }
|
||||||
|
selectedValue={ selection }
|
||||||
|
onTabSelect={ (_, data) => setSelection(data.value as SelectionType) }
|
||||||
|
>
|
||||||
|
<Tab value="general">{ i18n.t("options_page.general.title") }</Tab>
|
||||||
|
<Tab value="actions">{ i18n.t("options_page.actions.title") }</Tab>
|
||||||
|
<Tab value="storage">{ i18n.t("options_page.storage.title") }</Tab>
|
||||||
|
<Tab value="about">{ i18n.t("options_page.about.title") }</Tab>
|
||||||
|
</TabList>
|
||||||
|
|
||||||
|
<article className={ cls.article }>
|
||||||
|
{ selection === "general" && <GeneralSection /> }
|
||||||
|
{ selection === "actions" && <ActionsSection /> }
|
||||||
|
{ selection === "storage" && <StorageSection /> }
|
||||||
|
{ selection === "about" && <AboutSection /> }
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectionType = "general" | "actions" | "storage" | "about";
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export default async function exportData(): Promise<void>
|
||||||
|
{
|
||||||
|
const data: string = JSON.stringify({
|
||||||
|
local: await browser.storage.local.get(null),
|
||||||
|
sync: await browser.storage.sync.get(null)
|
||||||
|
});
|
||||||
|
|
||||||
|
const element: HTMLAnchorElement = document.createElement("a");
|
||||||
|
element.style.display = "none";
|
||||||
|
element.href = `data:application/json;charset=utf-8,${data}`;
|
||||||
|
element.setAttribute("download", "tabs-aside_data.json");
|
||||||
|
|
||||||
|
document.body.appendChild(element);
|
||||||
|
element.click();
|
||||||
|
document.body.removeChild(element);
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { sendMessage } from "@/utils/messaging";
|
||||||
|
|
||||||
|
export default async function importData(): Promise<boolean | null>
|
||||||
|
{
|
||||||
|
const element: HTMLInputElement = document.createElement("input");
|
||||||
|
element.style.display = "none";
|
||||||
|
element.hidden = true;
|
||||||
|
element.type = "file";
|
||||||
|
element.accept = ".json";
|
||||||
|
|
||||||
|
document.body.appendChild(element);
|
||||||
|
element.click();
|
||||||
|
|
||||||
|
await new Promise(resolve =>
|
||||||
|
{
|
||||||
|
const listener = () =>
|
||||||
|
{
|
||||||
|
element.removeEventListener("input", listener);
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
element.addEventListener("input", listener);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!element.files || element.files.length < 1)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const file: File = element.files[0];
|
||||||
|
const content: string = await file.text();
|
||||||
|
|
||||||
|
document.body.removeChild(element);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const data: any = JSON.parse(content);
|
||||||
|
|
||||||
|
if (data.local)
|
||||||
|
await browser.storage.local.set(data.local);
|
||||||
|
|
||||||
|
if (data.sync)
|
||||||
|
await browser.storage.sync.set(data.sync);
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
console.error("Failed to parse JSON", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessage("refreshCollections", undefined);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Tabs aside</title>
|
||||||
|
<style type="text/css">
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 600px;
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./sidepanel/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||||
|
|
||||||
|
export const useStyles_CollectionView = makeStyles({
|
||||||
|
root:
|
||||||
|
{
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
|
border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`,
|
||||||
|
borderRadius: tokens.borderRadiusLarge,
|
||||||
|
display: "flex",
|
||||||
|
flexFlow: "column",
|
||||||
|
|
||||||
|
"--border": tokens.colorNeutralForeground1,
|
||||||
|
|
||||||
|
"&:hover .CollectionView__toolbar, &:focus-within .CollectionView__toolbar":
|
||||||
|
{
|
||||||
|
display: "flex"
|
||||||
|
},
|
||||||
|
|
||||||
|
"&:hover":
|
||||||
|
{
|
||||||
|
boxShadow: tokens.shadow4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
color:
|
||||||
|
{
|
||||||
|
border: `${tokens.strokeWidthThick} solid var(--border)`
|
||||||
|
},
|
||||||
|
verticalRoot:
|
||||||
|
{
|
||||||
|
height: "560px"
|
||||||
|
},
|
||||||
|
empty:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
flexFlow: "column",
|
||||||
|
flexGrow: 1,
|
||||||
|
margin: `${tokens.spacingVerticalNone} ${tokens.spacingHorizontalSNudge}`,
|
||||||
|
marginBottom: tokens.spacingVerticalSNudge,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: tokens.spacingVerticalS,
|
||||||
|
padding: `${tokens.spacingVerticalXL} ${tokens.spacingHorizontalL}`,
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
height: "144px"
|
||||||
|
},
|
||||||
|
emptyText:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
flexFlow: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: tokens.spacingVerticalXS
|
||||||
|
},
|
||||||
|
emptyCaption:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
columnGap: tokens.spacingHorizontalXS
|
||||||
|
},
|
||||||
|
list:
|
||||||
|
{
|
||||||
|
display: "grid",
|
||||||
|
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
|
||||||
|
columnGap: tokens.spacingHorizontalS,
|
||||||
|
rowGap: tokens.spacingHorizontalSNudge,
|
||||||
|
overflowX: "auto",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
maxWidth: "100%",
|
||||||
|
gridAutoFlow: "column"
|
||||||
|
},
|
||||||
|
verticalList:
|
||||||
|
{
|
||||||
|
gridAutoFlow: "row",
|
||||||
|
width: "100%",
|
||||||
|
paddingBottom: tokens.spacingVerticalS
|
||||||
|
},
|
||||||
|
dragOverlay:
|
||||||
|
{
|
||||||
|
cursor: "grabbing !important",
|
||||||
|
transform: "scale(1.05)",
|
||||||
|
boxShadow: `${tokens.shadow16} !important`,
|
||||||
|
"& > div":
|
||||||
|
{
|
||||||
|
pointerEvents: "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sorting:
|
||||||
|
{
|
||||||
|
pointerEvents: "none"
|
||||||
|
},
|
||||||
|
dragging:
|
||||||
|
{
|
||||||
|
visibility: "hidden"
|
||||||
|
},
|
||||||
|
draggingOver:
|
||||||
|
{
|
||||||
|
backgroundColor: tokens.colorBrandBackground2
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import CollectionHeader from "@/entrypoints/sidepanel/components/collections/CollectionHeader";
|
||||||
|
import useDndItem from "@/entrypoints/sidepanel/hooks/useDndItem";
|
||||||
|
import { useGroupColors } from "@/hooks/useGroupColors";
|
||||||
|
import { CollectionItem } from "@/models/CollectionModels";
|
||||||
|
import { horizontalListSortingStrategy, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
|
import { Body1Strong, mergeClasses } from "@fluentui/react-components";
|
||||||
|
import { CollectionsRegular } from "@fluentui/react-icons";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import CollectionContext from "../contexts/CollectionContext";
|
||||||
|
import { useCollections } from "../contexts/CollectionsProvider";
|
||||||
|
import { useStyles_CollectionView } from "./CollectionView.styles";
|
||||||
|
import GroupView from "./GroupView";
|
||||||
|
import TabView from "./TabView";
|
||||||
|
|
||||||
|
export default function CollectionView({ collection, index: collectionIndex, dragOverlay }: CollectionViewProps): ReactElement
|
||||||
|
{
|
||||||
|
const { tilesView } = useCollections();
|
||||||
|
const {
|
||||||
|
setNodeRef,
|
||||||
|
nodeProps,
|
||||||
|
setActivatorNodeRef,
|
||||||
|
activatorProps,
|
||||||
|
activeItem, isCurrentlySorting, isBeingDragged, isActiveOverThis: isOver
|
||||||
|
} = useDndItem({ id: collectionIndex.toString(), data: { indices: [collectionIndex], item: collection } });
|
||||||
|
|
||||||
|
const isActiveOverThis: boolean = isOver && activeItem?.item.type !== "collection";
|
||||||
|
|
||||||
|
const tabCount: number = useMemo(() => collection.items.flatMap(i => i.type === "group" ? i.items : i).length, [collection.items]);
|
||||||
|
const hasPinnedGroup: boolean = useMemo(() => collection.items.length > 0 &&
|
||||||
|
(collection.items[0].type === "group" && collection.items[0].pinned === true), [collection.items]);
|
||||||
|
|
||||||
|
const cls = useStyles_CollectionView();
|
||||||
|
const colorCls = useGroupColors();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollectionContext.Provider value={ { collection, collectionIndex, tabCount, hasPinnedGroup } }>
|
||||||
|
<div
|
||||||
|
ref={ setNodeRef } { ...nodeProps }
|
||||||
|
className={ mergeClasses(
|
||||||
|
cls.root,
|
||||||
|
collection.color && colorCls[collection.color],
|
||||||
|
collection.color && cls.color,
|
||||||
|
!tilesView && cls.verticalRoot,
|
||||||
|
dragOverlay && cls.dragOverlay,
|
||||||
|
isBeingDragged && cls.dragging,
|
||||||
|
isCurrentlySorting && cls.sorting,
|
||||||
|
isActiveOverThis && cls.draggingOver
|
||||||
|
) }
|
||||||
|
>
|
||||||
|
|
||||||
|
<CollectionHeader dragHandleProps={ activatorProps } dragHandleRef={ setActivatorNodeRef } />
|
||||||
|
|
||||||
|
{ collection.items.length < 1 ?
|
||||||
|
<div className={ cls.empty }>
|
||||||
|
<CollectionsRegular fontSize={ 32 } />
|
||||||
|
<Body1Strong>{ i18n.t("collections.empty") }</Body1Strong>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
<div className={ mergeClasses(cls.list, !tilesView && cls.verticalList) }>
|
||||||
|
<SortableContext
|
||||||
|
items={ collection.items.map((_, index) => [collectionIndex, index].join("/")) }
|
||||||
|
strategy={ tilesView ? horizontalListSortingStrategy : verticalListSortingStrategy }
|
||||||
|
>
|
||||||
|
{ collection.items.map((i, index) =>
|
||||||
|
i.type === "group" ?
|
||||||
|
<GroupView
|
||||||
|
key={ index } group={ i } indices={ [collectionIndex, index] } />
|
||||||
|
:
|
||||||
|
<TabView key={ index } tab={ i } indices={ [collectionIndex, index] } />
|
||||||
|
) }
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div >
|
||||||
|
</CollectionContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollectionViewProps =
|
||||||
|
{
|
||||||
|
collection: CollectionItem;
|
||||||
|
index: number;
|
||||||
|
dragOverlay?: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { makeStyles, shorthands, tokens } from "@fluentui/react-components";
|
||||||
|
|
||||||
|
export const useStyles_EditDialog = makeStyles({
|
||||||
|
surface:
|
||||||
|
{
|
||||||
|
"--border": tokens.colorTransparentStroke,
|
||||||
|
...shorthands.borderWidth(tokens.strokeWidthThick),
|
||||||
|
...shorthands.borderColor("var(--border)")
|
||||||
|
},
|
||||||
|
content:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
flexFlow: "column",
|
||||||
|
gap: tokens.spacingVerticalS
|
||||||
|
},
|
||||||
|
colorPicker:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
rowGap: tokens.spacingVerticalS,
|
||||||
|
columnGap: tokens.spacingVerticalS
|
||||||
|
},
|
||||||
|
colorButton:
|
||||||
|
{
|
||||||
|
"&[aria-pressed=true]":
|
||||||
|
{
|
||||||
|
color: "var(--text) !important",
|
||||||
|
backgroundColor: "var(--border) !important",
|
||||||
|
|
||||||
|
"& .fui-Button__icon":
|
||||||
|
{
|
||||||
|
color: "var(--text)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colorButton_icon:
|
||||||
|
{
|
||||||
|
color: "var(--border)"
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { getCollectionTitle } from "@/entrypoints/sidepanel/utils/getCollectionTitle";
|
||||||
|
import { useGroupColors } from "@/hooks/useGroupColors";
|
||||||
|
import { CollectionItem, GroupItem } from "@/models/CollectionModels";
|
||||||
|
import * as fui from "@fluentui/react-components";
|
||||||
|
import { Circle20Filled, CircleOff20Regular, Pin20Filled, Rename20Regular } from "@fluentui/react-icons";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import { useStyles_EditDialog } from "./EditDialog.styles";
|
||||||
|
|
||||||
|
export default function EditDialog(props: GroupEditDialogProps): ReactElement
|
||||||
|
{
|
||||||
|
const [title, setTitle] = useState<string>(
|
||||||
|
(props.type === "collection"
|
||||||
|
? props.collection?.title :
|
||||||
|
(props.group?.pinned !== true ? props.group?.title : ""))
|
||||||
|
?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const [color, setColor] = useState<chrome.tabGroups.ColorEnum | undefined | "pinned">(
|
||||||
|
props.type === "collection"
|
||||||
|
? props.collection?.color :
|
||||||
|
props.group?.pinned === true ? "pinned" : (props.group?.color ?? "blue")
|
||||||
|
);
|
||||||
|
|
||||||
|
const cls = useStyles_EditDialog();
|
||||||
|
const colorCls = useGroupColors();
|
||||||
|
|
||||||
|
const handleSave = () =>
|
||||||
|
{
|
||||||
|
if (props.type === "collection")
|
||||||
|
props.onSave({
|
||||||
|
type: "collection",
|
||||||
|
timestamp: props.collection?.timestamp ?? Date.now(),
|
||||||
|
color: (color === "pinned") ? undefined : color!,
|
||||||
|
title,
|
||||||
|
items: props.collection?.items ?? []
|
||||||
|
});
|
||||||
|
else if (color === "pinned")
|
||||||
|
props.onSave({
|
||||||
|
type: "group",
|
||||||
|
pinned: true,
|
||||||
|
items: props.group?.items ?? []
|
||||||
|
});
|
||||||
|
else
|
||||||
|
props.onSave({
|
||||||
|
type: "group",
|
||||||
|
pinned: false,
|
||||||
|
color: color!,
|
||||||
|
title,
|
||||||
|
items: props.group?.items ?? []
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fui.DialogSurface className={ fui.mergeClasses(cls.surface, (color && color !== "pinned") && colorCls[color]) }>
|
||||||
|
<fui.DialogBody>
|
||||||
|
<fui.DialogTitle>
|
||||||
|
{
|
||||||
|
props.type === "collection" ?
|
||||||
|
i18n.t(`dialogs.edit.title.${props.collection ? "edit" : "new"}_collection`) :
|
||||||
|
i18n.t(`dialogs.edit.title.${props.group ? "edit" : "new"}_group`)
|
||||||
|
}
|
||||||
|
</fui.DialogTitle>
|
||||||
|
|
||||||
|
<fui.DialogContent>
|
||||||
|
<form className={ cls.content }>
|
||||||
|
<fui.Field label={ i18n.t("dialogs.edit.collection_title") }>
|
||||||
|
<fui.Input
|
||||||
|
contentBefore={ <Rename20Regular /> }
|
||||||
|
disabled={ color === "pinned" }
|
||||||
|
placeholder={
|
||||||
|
props.type === "collection" ? getCollectionTitle(props.collection) : ""
|
||||||
|
}
|
||||||
|
value={ color === "pinned" ? i18n.t("groups.pinned") : title }
|
||||||
|
onChange={ (_, e) => setTitle(e.value) } />
|
||||||
|
</fui.Field>
|
||||||
|
<fui.Field label="Color">
|
||||||
|
<div className={ cls.colorPicker }>
|
||||||
|
{ (props.type === "group" && (!props.hidePinned || props.group?.pinned)) &&
|
||||||
|
<fui.ToggleButton
|
||||||
|
checked={ color === "pinned" }
|
||||||
|
onClick={ () => setColor("pinned") }
|
||||||
|
icon={ <Pin20Filled /> }
|
||||||
|
shape="circular"
|
||||||
|
>
|
||||||
|
{ i18n.t("groups.pinned") }
|
||||||
|
</fui.ToggleButton>
|
||||||
|
}
|
||||||
|
{ props.type === "collection" &&
|
||||||
|
<fui.ToggleButton
|
||||||
|
checked={ color === undefined }
|
||||||
|
onClick={ () => setColor(undefined) }
|
||||||
|
icon={ <CircleOff20Regular /> }
|
||||||
|
shape="circular"
|
||||||
|
>
|
||||||
|
{ i18n.t("colors.none") }
|
||||||
|
</fui.ToggleButton>
|
||||||
|
}
|
||||||
|
{ Object.keys(colorCls).map(i =>
|
||||||
|
<fui.ToggleButton
|
||||||
|
checked={ color === i }
|
||||||
|
onClick={ () => setColor(i as chrome.tabGroups.ColorEnum) }
|
||||||
|
className={ fui.mergeClasses(cls.colorButton, colorCls[i as chrome.tabGroups.ColorEnum]) }
|
||||||
|
icon={ {
|
||||||
|
className: cls.colorButton_icon,
|
||||||
|
children: <Circle20Filled />
|
||||||
|
} }
|
||||||
|
key={ i }
|
||||||
|
shape="circular"
|
||||||
|
>
|
||||||
|
{ i18n.t(`colors.${i as chrome.tabGroups.ColorEnum}`) }
|
||||||
|
</fui.ToggleButton>
|
||||||
|
) }
|
||||||
|
</div>
|
||||||
|
</fui.Field>
|
||||||
|
</form>
|
||||||
|
</fui.DialogContent>
|
||||||
|
|
||||||
|
<fui.DialogActions>
|
||||||
|
<fui.DialogTrigger disableButtonEnhancement>
|
||||||
|
<fui.Button appearance="primary" onClick={ handleSave }>{ i18n.t("common.actions.save") }</fui.Button>
|
||||||
|
</fui.DialogTrigger>
|
||||||
|
<fui.DialogTrigger disableButtonEnhancement>
|
||||||
|
<fui.Button appearance="subtle">{ i18n.t("common.actions.cancel") }</fui.Button>
|
||||||
|
</fui.DialogTrigger>
|
||||||
|
</fui.DialogActions>
|
||||||
|
</fui.DialogBody>
|
||||||
|
</fui.DialogSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupEditDialogProps =
|
||||||
|
{
|
||||||
|
type: "collection";
|
||||||
|
collection?: CollectionItem;
|
||||||
|
onSave: (item: CollectionItem) => void;
|
||||||
|
} |
|
||||||
|
{
|
||||||
|
type: "group";
|
||||||
|
hidePinned?: boolean;
|
||||||
|
group?: GroupItem;
|
||||||
|
onSave: (item: GroupItem) => void;
|
||||||
|
};
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||||
|
|
||||||
|
export const useStyles_GroupView = makeStyles({
|
||||||
|
root:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
flexFlow: "column",
|
||||||
|
alignSelf: "normal",
|
||||||
|
|
||||||
|
padding: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalS}`,
|
||||||
|
paddingBottom: tokens.spacingVerticalNone,
|
||||||
|
borderRadius: tokens.borderRadiusLarge,
|
||||||
|
|
||||||
|
"&:hover .GroupView-toolbar, &:focus-within .GroupView-toolbar":
|
||||||
|
{
|
||||||
|
visibility: "visible"
|
||||||
|
},
|
||||||
|
|
||||||
|
"&:hover":
|
||||||
|
{
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1Hover
|
||||||
|
}
|
||||||
|
},
|
||||||
|
header:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: tokens.spacingHorizontalM,
|
||||||
|
|
||||||
|
borderBottom: `${tokens.strokeWidthThick} solid var(--border)`,
|
||||||
|
borderBottomLeftRadius: tokens.borderRadiusLarge
|
||||||
|
},
|
||||||
|
verticalHeader:
|
||||||
|
{
|
||||||
|
borderBottomLeftRadius: tokens.borderRadiusNone
|
||||||
|
},
|
||||||
|
title:
|
||||||
|
{
|
||||||
|
display: "grid",
|
||||||
|
gridAutoFlow: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "12px",
|
||||||
|
minWidth: "24px",
|
||||||
|
gap: tokens.spacingHorizontalXS,
|
||||||
|
width: "max-content",
|
||||||
|
maxWidth: "160px",
|
||||||
|
|
||||||
|
padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`,
|
||||||
|
paddingBottom: tokens.spacingVerticalXS,
|
||||||
|
marginBottom: "-2px",
|
||||||
|
|
||||||
|
border: `${tokens.strokeWidthThick} solid var(--border)`,
|
||||||
|
borderRadius: `${tokens.borderRadiusLarge} ${tokens.borderRadiusLarge} ${tokens.borderRadiusNone} ${tokens.borderRadiusLarge}`,
|
||||||
|
borderBottom: "none",
|
||||||
|
backgroundColor: "var(--border)",
|
||||||
|
color: "var(--text)"
|
||||||
|
},
|
||||||
|
verticalTitle:
|
||||||
|
{
|
||||||
|
borderBottomLeftRadius: tokens.borderRadiusNone
|
||||||
|
},
|
||||||
|
pinned:
|
||||||
|
{
|
||||||
|
backgroundColor: "transparent"
|
||||||
|
},
|
||||||
|
toolbar:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
gap: tokens.spacingHorizontalS,
|
||||||
|
visibility: "hidden",
|
||||||
|
|
||||||
|
"@media (pointer: coarse)":
|
||||||
|
{
|
||||||
|
visibility: "visible"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showToolbar:
|
||||||
|
{
|
||||||
|
visibility: "visible"
|
||||||
|
},
|
||||||
|
openAllLink:
|
||||||
|
{
|
||||||
|
whiteSpace: "nowrap"
|
||||||
|
},
|
||||||
|
empty:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
flexFlow: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
minWidth: "160px",
|
||||||
|
height: "120px",
|
||||||
|
marginBottom: tokens.spacingVerticalSNudge
|
||||||
|
},
|
||||||
|
verticalEmpty:
|
||||||
|
{
|
||||||
|
height: "auto",
|
||||||
|
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`
|
||||||
|
},
|
||||||
|
list:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
columnGap: tokens.spacingHorizontalS,
|
||||||
|
rowGap: tokens.spacingHorizontalSNudge,
|
||||||
|
height: "100%"
|
||||||
|
},
|
||||||
|
verticalList:
|
||||||
|
{
|
||||||
|
flexFlow: "column"
|
||||||
|
},
|
||||||
|
listContainer:
|
||||||
|
{
|
||||||
|
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalXS}`,
|
||||||
|
paddingBottom: tokens.spacingVerticalNone,
|
||||||
|
height: "100%"
|
||||||
|
},
|
||||||
|
verticalListContainer:
|
||||||
|
{
|
||||||
|
borderLeft: `${tokens.strokeWidthThick} solid var(--border)`,
|
||||||
|
padding: tokens.spacingVerticalSNudge,
|
||||||
|
marginBottom: tokens.spacingVerticalSNudge,
|
||||||
|
borderTopLeftRadius: tokens.borderRadiusNone,
|
||||||
|
borderBottomLeftRadius: tokens.borderRadiusNone,
|
||||||
|
borderTop: "none"
|
||||||
|
},
|
||||||
|
pinnedColor:
|
||||||
|
{
|
||||||
|
"--border": tokens.colorNeutralStrokeAccessible,
|
||||||
|
"--text": tokens.colorNeutralForeground1
|
||||||
|
},
|
||||||
|
dragOverlay:
|
||||||
|
{
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||||
|
transform: "scale(1.05)",
|
||||||
|
cursor: "grabbing !important",
|
||||||
|
boxShadow: `${tokens.shadow16} !important`,
|
||||||
|
"& > div":
|
||||||
|
{
|
||||||
|
pointerEvents: "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dragging:
|
||||||
|
{
|
||||||
|
visibility: "hidden"
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import GroupContext from "@/entrypoints/sidepanel/contexts/GroupContext";
|
||||||
|
import useDndItem from "@/entrypoints/sidepanel/hooks/useDndItem";
|
||||||
|
import { openGroup } from "@/entrypoints/sidepanel/utils/opener";
|
||||||
|
import { useGroupColors } from "@/hooks/useGroupColors";
|
||||||
|
import useSettings from "@/hooks/useSettings";
|
||||||
|
import { GroupItem } from "@/models/CollectionModels";
|
||||||
|
import { horizontalListSortingStrategy, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
|
import { Caption1Strong, Link, mergeClasses, Tooltip } from "@fluentui/react-components";
|
||||||
|
import { Pin16Filled, WebAssetRegular } from "@fluentui/react-icons";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import { useCollections } from "../contexts/CollectionsProvider";
|
||||||
|
import GroupDropZone from "./collections/GroupDropZone";
|
||||||
|
import GroupMoreMenu from "./collections/GroupMoreMenu";
|
||||||
|
import { useStyles_GroupView } from "./GroupView.styles";
|
||||||
|
import TabView from "./TabView";
|
||||||
|
|
||||||
|
export default function GroupView({ group, indices, dragOverlay }: GroupViewProps): ReactElement
|
||||||
|
{
|
||||||
|
const [alwaysShowToolbars] = useSettings("alwaysShowToolbars");
|
||||||
|
const { tilesView } = useCollections();
|
||||||
|
|
||||||
|
const groupId: string = useMemo(() => indices.join("/"), [indices]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
setNodeRef, nodeProps,
|
||||||
|
setActivatorNodeRef, activatorProps,
|
||||||
|
activeItem: active, isBeingDragged
|
||||||
|
} = useDndItem({ id: groupId, data: { indices, item: group }, disabled: group.pinned });
|
||||||
|
|
||||||
|
const disableDropZone: boolean = useMemo(
|
||||||
|
() => active !== null &&
|
||||||
|
(active.item.type !== "tab" || (active.indices[0] === indices[0] && active.indices[1] === indices[1])),
|
||||||
|
[active, indices]);
|
||||||
|
const disableSorting: boolean = useMemo(
|
||||||
|
() => active !== null && (active.item.type !== "tab" || active.indices[0] !== indices[0]),
|
||||||
|
[active, indices]);
|
||||||
|
|
||||||
|
const cls = useStyles_GroupView();
|
||||||
|
const colorCls = useGroupColors();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GroupContext.Provider value={ { group, indices } }>
|
||||||
|
<div
|
||||||
|
ref={ setNodeRef } { ...nodeProps }
|
||||||
|
className={ mergeClasses(
|
||||||
|
cls.root,
|
||||||
|
group.pinned === true ? cls.pinnedColor : colorCls[group.color],
|
||||||
|
isBeingDragged && cls.dragging,
|
||||||
|
dragOverlay && cls.dragOverlay
|
||||||
|
) }
|
||||||
|
>
|
||||||
|
<div className={ mergeClasses(cls.header, !tilesView && cls.verticalHeader) }>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={ setActivatorNodeRef } { ...activatorProps }
|
||||||
|
className={ mergeClasses(cls.title, group.pinned && cls.pinned, !tilesView && cls.verticalTitle) }
|
||||||
|
>
|
||||||
|
{ group.pinned === true ?
|
||||||
|
<>
|
||||||
|
<Pin16Filled />
|
||||||
|
<Caption1Strong truncate wrap={ false }>{ i18n.t("groups.pinned") }</Caption1Strong>
|
||||||
|
</>
|
||||||
|
:
|
||||||
|
<Tooltip relationship="description" content={ group.title ?? "" }>
|
||||||
|
<Caption1Strong truncate wrap={ false }>{ group.title }</Caption1Strong>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={ mergeClasses(cls.toolbar, "GroupView-toolbar", alwaysShowToolbars === true && cls.showToolbar) }>
|
||||||
|
{ group.items.length > 0 &&
|
||||||
|
<Link className={ cls.openAllLink } onClick={ () => openGroup(group, false) }>
|
||||||
|
{ i18n.t("groups.open") }
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
|
||||||
|
<GroupMoreMenu />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GroupDropZone
|
||||||
|
disabled={ disableDropZone }
|
||||||
|
className={ mergeClasses(cls.listContainer, !tilesView && cls.verticalListContainer) }
|
||||||
|
>
|
||||||
|
{ group.items.length < 1 ?
|
||||||
|
<div className={ mergeClasses(cls.empty, !tilesView && cls.verticalEmpty) }>
|
||||||
|
<WebAssetRegular fontSize={ 32 } />
|
||||||
|
<Caption1Strong>{ i18n.t("groups.empty") }</Caption1Strong>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
<div className={ mergeClasses(cls.list, !tilesView && cls.verticalList) }>
|
||||||
|
<SortableContext
|
||||||
|
items={ group.items.map((_, index) => [...indices, index].join("/")) }
|
||||||
|
disabled={ disableSorting }
|
||||||
|
strategy={ !tilesView ? verticalListSortingStrategy : horizontalListSortingStrategy }
|
||||||
|
>
|
||||||
|
{ group.items.map((i, index) =>
|
||||||
|
<TabView key={ index } tab={ i } indices={ [...indices, index] } />
|
||||||
|
) }
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</GroupDropZone>
|
||||||
|
</div>
|
||||||
|
</GroupContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupViewProps =
|
||||||
|
{
|
||||||
|
group: GroupItem;
|
||||||
|
indices: number[];
|
||||||
|
dragOverlay?: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||||
|
|
||||||
|
export const useStyles_TabView = makeStyles({
|
||||||
|
root:
|
||||||
|
{
|
||||||
|
display: "grid",
|
||||||
|
position: "relative",
|
||||||
|
|
||||||
|
width: "160px",
|
||||||
|
height: "120px",
|
||||||
|
marginBottom: tokens.spacingVerticalSNudge,
|
||||||
|
|
||||||
|
border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke3}`,
|
||||||
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
|
|
||||||
|
cursor: "pointer",
|
||||||
|
textDecoration: "none !important",
|
||||||
|
userSelect: "none",
|
||||||
|
|
||||||
|
"&:hover button, &:focus-within button":
|
||||||
|
{
|
||||||
|
display: "inline-flex"
|
||||||
|
},
|
||||||
|
|
||||||
|
"&:hover":
|
||||||
|
{
|
||||||
|
boxShadow: tokens.shadow4
|
||||||
|
},
|
||||||
|
|
||||||
|
"&:focus-visible":
|
||||||
|
{
|
||||||
|
outline: `2px solid ${tokens.colorStrokeFocus2}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
listView:
|
||||||
|
{
|
||||||
|
width: "100%",
|
||||||
|
height: "min-content",
|
||||||
|
marginBottom: tokens.spacingVerticalNone
|
||||||
|
},
|
||||||
|
image:
|
||||||
|
{
|
||||||
|
zIndex: 0,
|
||||||
|
position: "absolute",
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
|
||||||
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
|
objectFit: "cover"
|
||||||
|
},
|
||||||
|
header:
|
||||||
|
{
|
||||||
|
zIndex: 1,
|
||||||
|
alignSelf: "end",
|
||||||
|
minHeight: "32px",
|
||||||
|
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "auto 1fr auto",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: tokens.spacingHorizontalSNudge,
|
||||||
|
paddingLeft: tokens.spacingHorizontalS,
|
||||||
|
|
||||||
|
borderBottomLeftRadius: tokens.borderRadiusMedium,
|
||||||
|
borderBottomRightRadius: tokens.borderRadiusMedium,
|
||||||
|
|
||||||
|
backgroundColor: tokens.colorSubtleBackgroundLightAlphaHover,
|
||||||
|
color: tokens.colorNeutralForeground1,
|
||||||
|
"-webkit-backdrop-filer": "blur(4px)",
|
||||||
|
backdropFilter: "blur(4px)"
|
||||||
|
},
|
||||||
|
icon:
|
||||||
|
{
|
||||||
|
cursor: "grab",
|
||||||
|
|
||||||
|
"&:active":
|
||||||
|
{
|
||||||
|
cursor: "grabbing"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title:
|
||||||
|
{
|
||||||
|
overflowX: "hidden",
|
||||||
|
justifySelf: "start",
|
||||||
|
maxWidth: "100%"
|
||||||
|
},
|
||||||
|
deleteButton:
|
||||||
|
{
|
||||||
|
display: "none",
|
||||||
|
|
||||||
|
"@media (pointer: coarse)":
|
||||||
|
{
|
||||||
|
display: "inline-flex"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showDeleteButton:
|
||||||
|
{
|
||||||
|
display: "inline-flex"
|
||||||
|
},
|
||||||
|
dragOverlay:
|
||||||
|
{
|
||||||
|
cursor: "grabbing !important",
|
||||||
|
transform: "scale(1.05)",
|
||||||
|
boxShadow: `${tokens.shadow16} !important`,
|
||||||
|
"& > div":
|
||||||
|
{
|
||||||
|
pointerEvents: "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dragging:
|
||||||
|
{
|
||||||
|
visibility: "hidden"
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import faviconPlaceholder from "@/assets/FaviconPlaceholder.svg";
|
||||||
|
import pagePlaceholder from "@/assets/PagePlaceholder.svg";
|
||||||
|
import { useDialog } from "@/contexts/DialogProvider";
|
||||||
|
import { useCollections } from "@/entrypoints/sidepanel/contexts/CollectionsProvider";
|
||||||
|
import useDndItem from "@/entrypoints/sidepanel/hooks/useDndItem";
|
||||||
|
import useSettings from "@/hooks/useSettings";
|
||||||
|
import { TabItem } from "@/models/CollectionModels";
|
||||||
|
import { Button, Caption1, Link, mergeClasses, Tooltip } from "@fluentui/react-components";
|
||||||
|
import { Dismiss20Regular } from "@fluentui/react-icons";
|
||||||
|
import { MouseEventHandler, ReactElement } from "react";
|
||||||
|
import { useStyles_TabView } from "./TabView.styles";
|
||||||
|
|
||||||
|
export default function TabView({ tab, indices, dragOverlay }: TabViewProps): ReactElement
|
||||||
|
{
|
||||||
|
const { removeItem, graphics, tilesView } = useCollections();
|
||||||
|
const {
|
||||||
|
setNodeRef, setActivatorNodeRef,
|
||||||
|
nodeProps, activatorProps, isBeingDragged
|
||||||
|
} = useDndItem({ id: indices.join("/"), data: { indices, item: tab } });
|
||||||
|
const dialog = useDialog();
|
||||||
|
|
||||||
|
const [deletePrompt] = useSettings("deletePrompt");
|
||||||
|
const [showToolbar] = useSettings("alwaysShowToolbars");
|
||||||
|
|
||||||
|
const cls = useStyles_TabView();
|
||||||
|
|
||||||
|
const handleDelete: MouseEventHandler<HTMLButtonElement> = (args) =>
|
||||||
|
{
|
||||||
|
args.preventDefault();
|
||||||
|
args.stopPropagation();
|
||||||
|
|
||||||
|
if (deletePrompt)
|
||||||
|
dialog.pushPrompt({
|
||||||
|
title: i18n.t("tabs.delete"),
|
||||||
|
content: i18n.t("common.delete_prompt"),
|
||||||
|
destructive: true,
|
||||||
|
confirmText: i18n.t("common.actions.delete"),
|
||||||
|
onConfirm: () => removeItem(...indices)
|
||||||
|
});
|
||||||
|
else
|
||||||
|
removeItem(...indices);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick: MouseEventHandler<HTMLAnchorElement> = (args) =>
|
||||||
|
{
|
||||||
|
args.preventDefault();
|
||||||
|
browser.tabs.create({ url: tab.url, active: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuxClick: MouseEventHandler<HTMLAnchorElement> = (args) =>
|
||||||
|
{
|
||||||
|
args.preventDefault();
|
||||||
|
|
||||||
|
if (args.button === 1)
|
||||||
|
browser.tabs.create({ url: tab.url, active: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
ref={ setNodeRef } { ...nodeProps }
|
||||||
|
href={ tab.url }
|
||||||
|
onClick={ handleClick } onAuxClick={ handleAuxClick }
|
||||||
|
className={ mergeClasses(
|
||||||
|
cls.root,
|
||||||
|
!tilesView && cls.listView,
|
||||||
|
isBeingDragged && cls.dragging,
|
||||||
|
dragOverlay && cls.dragOverlay
|
||||||
|
) }
|
||||||
|
>
|
||||||
|
{ tilesView &&
|
||||||
|
<img
|
||||||
|
src={ graphics[tab.url]?.preview ?? pagePlaceholder }
|
||||||
|
onError={ e => e.currentTarget.src = pagePlaceholder }
|
||||||
|
className={ cls.image } draggable={ false } />
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className={ cls.header }>
|
||||||
|
<img
|
||||||
|
ref={ setActivatorNodeRef } { ...activatorProps }
|
||||||
|
src={ graphics[tab.url]?.icon ?? faviconPlaceholder }
|
||||||
|
onError={ e => e.currentTarget.src = faviconPlaceholder }
|
||||||
|
height={ 20 } width={ 20 }
|
||||||
|
className={ cls.icon } draggable={ false } />
|
||||||
|
|
||||||
|
<Tooltip relationship="description" content={ tab.title ?? tab.url }>
|
||||||
|
<Caption1 truncate wrap={ false } className={ cls.title }>
|
||||||
|
{ tab.title ?? tab.url }
|
||||||
|
</Caption1>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip relationship="label" content={ i18n.t("tabs.delete") }>
|
||||||
|
<Button
|
||||||
|
className={ mergeClasses(cls.deleteButton, showToolbar === true && cls.showDeleteButton) }
|
||||||
|
appearance="subtle" icon={ <Dismiss20Regular /> }
|
||||||
|
onClick={ handleDelete } />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabViewProps =
|
||||||
|
{
|
||||||
|
tab: TabItem;
|
||||||
|
indices: number[];
|
||||||
|
dragOverlay?: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { getCollectionTitle } from "@/entrypoints/sidepanel/utils/getCollectionTitle";
|
||||||
|
import getSelectedTabs from "@/entrypoints/sidepanel/utils/getSelectedTabs";
|
||||||
|
import useSettings from "@/hooks/useSettings";
|
||||||
|
import { TabItem } from "@/models/CollectionModels";
|
||||||
|
import { Button, Caption1, makeStyles, mergeClasses, Subtitle2, tokens, Tooltip } from "@fluentui/react-components";
|
||||||
|
import { Add20Filled, Add20Regular, bundleIcon } from "@fluentui/react-icons";
|
||||||
|
import CollectionContext, { CollectionContextType } from "../../contexts/CollectionContext";
|
||||||
|
import { useCollections } from "../../contexts/CollectionsProvider";
|
||||||
|
import CollectionMoreButton from "./CollectionMoreButton";
|
||||||
|
import OpenCollectionButton from "./OpenCollectionButton";
|
||||||
|
|
||||||
|
export default function CollectionHeader({ dragHandleRef, dragHandleProps }: CollectionHeaderProps): React.ReactElement
|
||||||
|
{
|
||||||
|
const { updateCollection } = useCollections();
|
||||||
|
const { tabCount, collection, collectionIndex } = useContext<CollectionContextType>(CollectionContext);
|
||||||
|
const [alwaysShowToolbars] = useSettings("alwaysShowToolbars");
|
||||||
|
|
||||||
|
const AddIcon = bundleIcon(Add20Filled, Add20Regular);
|
||||||
|
|
||||||
|
const handleAddSelected = async () =>
|
||||||
|
{
|
||||||
|
const newTabs: TabItem[] = await getSelectedTabs();
|
||||||
|
updateCollection({ ...collection, items: [...collection.items, ...newTabs] }, collectionIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cls = useStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={ cls.header }>
|
||||||
|
<div className={ cls.title } ref={ dragHandleRef } { ...dragHandleProps }>
|
||||||
|
<Tooltip
|
||||||
|
relationship="description"
|
||||||
|
content={ getCollectionTitle(collection) }
|
||||||
|
positioning="above-start"
|
||||||
|
>
|
||||||
|
<Subtitle2 truncate wrap={ false } className={ cls.titleText }>
|
||||||
|
{ getCollectionTitle(collection) }
|
||||||
|
</Subtitle2>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Caption1>
|
||||||
|
{ i18n.t("collections.tabs_count", [tabCount]) }
|
||||||
|
</Caption1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
mergeClasses(
|
||||||
|
cls.toolbar,
|
||||||
|
"CollectionView__toolbar",
|
||||||
|
alwaysShowToolbars === true && cls.showToolbar
|
||||||
|
) }
|
||||||
|
>
|
||||||
|
{ tabCount < 1 ?
|
||||||
|
<Button icon={ <AddIcon /> } appearance="subtle" onClick={ handleAddSelected }>
|
||||||
|
{ i18n.t("collections.menu.add_selected") }
|
||||||
|
</Button>
|
||||||
|
:
|
||||||
|
<OpenCollectionButton />
|
||||||
|
}
|
||||||
|
|
||||||
|
<CollectionMoreButton onAddSelected={ handleAddSelected } />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollectionHeaderProps =
|
||||||
|
{
|
||||||
|
dragHandleRef?: React.LegacyRef<HTMLDivElement>;
|
||||||
|
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
header:
|
||||||
|
{
|
||||||
|
color: "var(--border)",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr auto",
|
||||||
|
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalS}`,
|
||||||
|
paddingBottom: tokens.spacingVerticalS
|
||||||
|
},
|
||||||
|
title:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
flexFlow: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
overflow: "hidden"
|
||||||
|
},
|
||||||
|
titleText:
|
||||||
|
{
|
||||||
|
maxWidth: "100%"
|
||||||
|
},
|
||||||
|
toolbar:
|
||||||
|
{
|
||||||
|
display: "none",
|
||||||
|
gap: tokens.spacingHorizontalS,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
|
||||||
|
"@media (pointer: coarse)":
|
||||||
|
{
|
||||||
|
display: "flex"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showToolbar:
|
||||||
|
{
|
||||||
|
display: "flex"
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useDialog } from "@/contexts/DialogProvider";
|
||||||
|
import { useDangerStyles } from "@/hooks/useDangerStyles";
|
||||||
|
import useSettings from "@/hooks/useSettings";
|
||||||
|
import { Button, Menu, MenuDivider, MenuItem, MenuList, MenuPopover, MenuTrigger, Tooltip } from "@fluentui/react-components";
|
||||||
|
import * as ic from "@fluentui/react-icons";
|
||||||
|
import CollectionContext, { CollectionContextType } from "../../contexts/CollectionContext";
|
||||||
|
import { useCollections } from "../../contexts/CollectionsProvider";
|
||||||
|
import exportCollectionToBookmarks from "../../utils/exportCollectionToBookmarks";
|
||||||
|
import EditDialog from "../EditDialog";
|
||||||
|
|
||||||
|
export default function CollectionMoreButton({ onAddSelected }: CollectionMoreButtonProps): React.ReactElement
|
||||||
|
{
|
||||||
|
const { removeItem, updateCollection } = useCollections();
|
||||||
|
const { tabCount, hasPinnedGroup, collection, collectionIndex } = useContext<CollectionContextType>(CollectionContext);
|
||||||
|
const dialog = useDialog();
|
||||||
|
const [deletePrompt] = useSettings("deletePrompt");
|
||||||
|
|
||||||
|
const AddIcon = ic.bundleIcon(ic.Add20Filled, ic.Add20Regular);
|
||||||
|
const GroupIcon = ic.bundleIcon(ic.GroupList20Filled, ic.GroupList20Regular);
|
||||||
|
const EditIcon = ic.bundleIcon(ic.Edit20Filled, ic.Edit20Regular);
|
||||||
|
const DeleteIcon = ic.bundleIcon(ic.Delete20Filled, ic.Delete20Regular);
|
||||||
|
const PinnedIcon = ic.bundleIcon(ic.Pin20Filled, ic.Pin20Regular);
|
||||||
|
const BookmarkIcon = ic.bundleIcon(ic.BookmarkAdd20Filled, ic.BookmarkAdd20Regular);
|
||||||
|
|
||||||
|
const dangerCls = useDangerStyles();
|
||||||
|
|
||||||
|
const handleDelete = () =>
|
||||||
|
{
|
||||||
|
if (deletePrompt)
|
||||||
|
dialog.pushPrompt({
|
||||||
|
title: i18n.t("collections.menu.delete"),
|
||||||
|
content: i18n.t("common.delete_prompt"),
|
||||||
|
destructive: true,
|
||||||
|
confirmText: i18n.t("common.actions.delete"),
|
||||||
|
onConfirm: () => removeItem(collectionIndex)
|
||||||
|
});
|
||||||
|
else
|
||||||
|
removeItem(collectionIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () =>
|
||||||
|
dialog.pushCustom(
|
||||||
|
<EditDialog
|
||||||
|
type="collection"
|
||||||
|
collection={ collection }
|
||||||
|
onSave={ item => updateCollection(item, collectionIndex) } />
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateGroup = () =>
|
||||||
|
dialog.pushCustom(
|
||||||
|
<EditDialog
|
||||||
|
type="group"
|
||||||
|
hidePinned={ hasPinnedGroup }
|
||||||
|
onSave={ group => updateCollection({ ...collection, items: [...collection.items, group] }, collectionIndex) } />
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddPinnedGroup = () =>
|
||||||
|
{
|
||||||
|
updateCollection({
|
||||||
|
...collection,
|
||||||
|
items: [
|
||||||
|
{ type: "group", pinned: true, items: [] },
|
||||||
|
...collection.items
|
||||||
|
]
|
||||||
|
}, collectionIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu>
|
||||||
|
<Tooltip relationship="label" content={ i18n.t("common.tooltips.more") }>
|
||||||
|
<MenuTrigger>
|
||||||
|
<Button appearance="subtle" icon={ <ic.MoreVertical20Regular /> } />
|
||||||
|
</MenuTrigger>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<MenuPopover>
|
||||||
|
<MenuList>
|
||||||
|
{ tabCount > 0 &&
|
||||||
|
<MenuItem icon={ <AddIcon /> } onClick={ () => onAddSelected?.() }>
|
||||||
|
{ i18n.t("collections.menu.add_selected") }
|
||||||
|
</MenuItem>
|
||||||
|
}
|
||||||
|
{ !import.meta.env.FIREFOX &&
|
||||||
|
<MenuItem icon={ <GroupIcon /> } onClick={ handleCreateGroup }>
|
||||||
|
{ i18n.t("collections.menu.add_group") }
|
||||||
|
</MenuItem>
|
||||||
|
}
|
||||||
|
{ (import.meta.env.FIREFOX && !hasPinnedGroup) &&
|
||||||
|
<MenuItem icon={ <PinnedIcon /> } onClick={ handleAddPinnedGroup }>
|
||||||
|
{ i18n.t("collections.menu.add_pinned") }
|
||||||
|
</MenuItem>
|
||||||
|
}
|
||||||
|
{ tabCount > 0 &&
|
||||||
|
<MenuItem icon={ <BookmarkIcon /> } onClick={ () => exportCollectionToBookmarks(collection) }>
|
||||||
|
{ i18n.t("collections.menu.export_bookmarks") }
|
||||||
|
</MenuItem>
|
||||||
|
}
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuItem icon={ <EditIcon /> } onClick={ handleEdit }>
|
||||||
|
{ i18n.t("collections.menu.edit") }
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem icon={ <DeleteIcon /> } className={ dangerCls.menuItem } onClick={ handleDelete }>
|
||||||
|
{ i18n.t("collections.menu.delete") }
|
||||||
|
</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
</MenuPopover>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollectionMoreButtonProps =
|
||||||
|
{
|
||||||
|
onAddSelected?: () => void;
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useDroppable } from "@dnd-kit/core";
|
||||||
|
import { makeStyles, mergeClasses, tokens } from "@fluentui/react-components";
|
||||||
|
import GroupContext, { GroupContextType } from "../../contexts/GroupContext";
|
||||||
|
|
||||||
|
export default function GroupDropZone({ disabled, ...props }: DropZoneProps): React.ReactElement
|
||||||
|
{
|
||||||
|
const { group, indices } = useContext<GroupContextType>(GroupContext);
|
||||||
|
const id: string = indices.join("/") + "_dropzone";
|
||||||
|
const { isOver, setNodeRef, active } = useDroppable({ id, data: { indices, item: group }, disabled });
|
||||||
|
|
||||||
|
const isDragging = !disabled && active !== null;
|
||||||
|
const cls = useStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ isDragging ? setNodeRef : undefined } { ...props }
|
||||||
|
className={ mergeClasses(cls.root, isDragging && cls.dragging, isOver && cls.over, props.className) }
|
||||||
|
>
|
||||||
|
{ props.children }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DropZoneProps = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
|
||||||
|
& {
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
root:
|
||||||
|
{
|
||||||
|
borderRadius: tokens.borderRadiusLarge,
|
||||||
|
borderTopRightRadius: tokens.borderRadiusNone,
|
||||||
|
border: `${tokens.strokeWidthThin} solid transparent`
|
||||||
|
},
|
||||||
|
over:
|
||||||
|
{
|
||||||
|
backgroundColor: tokens.colorBrandBackground2,
|
||||||
|
border: `${tokens.strokeWidthThin} solid ${tokens.colorBrandStroke1}`
|
||||||
|
},
|
||||||
|
dragging:
|
||||||
|
{
|
||||||
|
border: `${tokens.strokeWidthThin} dashed ${tokens.colorNeutralStroke1}`
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useDialog } from "@/contexts/DialogProvider";
|
||||||
|
import EditDialog from "@/entrypoints/sidepanel/components/EditDialog";
|
||||||
|
import CollectionContext, { CollectionContextType } from "@/entrypoints/sidepanel/contexts/CollectionContext";
|
||||||
|
import { useCollections } from "@/entrypoints/sidepanel/contexts/CollectionsProvider";
|
||||||
|
import GroupContext, { GroupContextType } from "@/entrypoints/sidepanel/contexts/GroupContext";
|
||||||
|
import getSelectedTabs from "@/entrypoints/sidepanel/utils/getSelectedTabs";
|
||||||
|
import { useDangerStyles } from "@/hooks/useDangerStyles";
|
||||||
|
import useSettings from "@/hooks/useSettings";
|
||||||
|
import { TabItem } from "@/models/CollectionModels";
|
||||||
|
import { Button, Menu, MenuItem, MenuList, MenuPopover, MenuTrigger, Tooltip } from "@fluentui/react-components";
|
||||||
|
import * as ic from "@fluentui/react-icons";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import { openGroup } from "../../utils/opener";
|
||||||
|
|
||||||
|
export default function GroupMoreMenu(): ReactElement
|
||||||
|
{
|
||||||
|
const { group, indices } = useContext<GroupContextType>(GroupContext);
|
||||||
|
const { hasPinnedGroup } = useContext<CollectionContextType>(CollectionContext);
|
||||||
|
const [deletePrompt] = useSettings("deletePrompt");
|
||||||
|
const dialog = useDialog();
|
||||||
|
const { updateGroup, removeItem, ungroup } = useCollections();
|
||||||
|
|
||||||
|
const dangerCls = useDangerStyles();
|
||||||
|
|
||||||
|
const AddIcon = ic.bundleIcon(ic.Add20Filled, ic.Add20Regular);
|
||||||
|
const UngroupIcon = ic.bundleIcon(ic.FullScreenMaximize20Filled, ic.FullScreenMaximize20Regular);
|
||||||
|
const EditIcon = ic.bundleIcon(ic.Edit20Filled, ic.Edit20Regular);
|
||||||
|
const NewWindowIcon = ic.bundleIcon(ic.WindowNew20Filled, ic.WindowNew20Regular);
|
||||||
|
const DeleteIcon = ic.bundleIcon(ic.Delete20Filled, ic.Delete20Regular);
|
||||||
|
|
||||||
|
const handleDelete = () =>
|
||||||
|
{
|
||||||
|
if (deletePrompt)
|
||||||
|
dialog.pushPrompt({
|
||||||
|
title: i18n.t("groups.menu.delete"),
|
||||||
|
content: i18n.t("common.delete_prompt"),
|
||||||
|
confirmText: i18n.t("common.actions.delete"),
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: () => removeItem(...indices)
|
||||||
|
});
|
||||||
|
else
|
||||||
|
removeItem(...indices);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () =>
|
||||||
|
dialog.pushCustom(
|
||||||
|
<EditDialog
|
||||||
|
type="group"
|
||||||
|
group={ group }
|
||||||
|
hidePinned={ hasPinnedGroup }
|
||||||
|
onSave={ item => updateGroup(item, indices[0], indices[1]) } />
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddSelected = async () =>
|
||||||
|
{
|
||||||
|
const newTabs: TabItem[] = await getSelectedTabs();
|
||||||
|
updateGroup({ ...group, items: [...group.items, ...newTabs] }, indices[0], indices[1]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu>
|
||||||
|
<Tooltip relationship="label" content={ i18n.t("common.tooltips.more") }>
|
||||||
|
<MenuTrigger>
|
||||||
|
<Button size="small" appearance="subtle" icon={ <ic.MoreVertical20Regular /> } />
|
||||||
|
</MenuTrigger>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<MenuPopover>
|
||||||
|
<MenuList>
|
||||||
|
{ group.items.length > 0 &&
|
||||||
|
<MenuItem icon={ <NewWindowIcon /> } onClick={ () => openGroup(group, true) }>
|
||||||
|
{ i18n.t("groups.menu.new_window") }
|
||||||
|
</MenuItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MenuItem icon={ <AddIcon /> } onClick={ handleAddSelected }>
|
||||||
|
{ i18n.t("groups.menu.add_selected") }
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
{ (!import.meta.env.FIREFOX || group.pinned !== true) &&
|
||||||
|
<MenuItem icon={ <EditIcon /> } onClick={ handleEdit }>
|
||||||
|
{ i18n.t("groups.menu.edit") }
|
||||||
|
</MenuItem>
|
||||||
|
}
|
||||||
|
{ group.items.length > 0 &&
|
||||||
|
<MenuItem
|
||||||
|
className={ dangerCls.menuItem }
|
||||||
|
icon={ <UngroupIcon /> }
|
||||||
|
onClick={ () => ungroup(indices[0], indices[1]) }
|
||||||
|
>
|
||||||
|
{ i18n.t("groups.menu.ungroup") }
|
||||||
|
</MenuItem>
|
||||||
|
}
|
||||||
|
<MenuItem className={ dangerCls.menuItem } icon={ <DeleteIcon /> } onClick={ handleDelete }>
|
||||||
|
{ i18n.t("groups.menu.delete") }
|
||||||
|
</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
</MenuPopover>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useDialog } from "@/contexts/DialogProvider";
|
||||||
|
import useSettings from "@/hooks/useSettings";
|
||||||
|
import browserLocaleKey from "@/utils/browserLocaleKey";
|
||||||
|
import { Menu, MenuButtonProps, MenuItem, MenuList, MenuPopover, MenuTrigger, SplitButton } from "@fluentui/react-components";
|
||||||
|
import * as ic from "@fluentui/react-icons";
|
||||||
|
import CollectionContext, { CollectionContextType } from "../../contexts/CollectionContext";
|
||||||
|
import { useCollections } from "../../contexts/CollectionsProvider";
|
||||||
|
import { openCollection } from "../../utils/opener";
|
||||||
|
|
||||||
|
export default function OpenCollectionButton(): React.ReactElement
|
||||||
|
{
|
||||||
|
const [defaultAction] = useSettings("defaultRestoreAction");
|
||||||
|
const { removeItem } = useCollections();
|
||||||
|
const dialog = useDialog();
|
||||||
|
const { collection, collectionIndex } = useContext<CollectionContextType>(CollectionContext);
|
||||||
|
|
||||||
|
const OpenIcon = ic.bundleIcon(ic.Open20Filled, ic.Open20Regular);
|
||||||
|
const RestoreIcon = ic.bundleIcon(ic.ArrowExportRtl20Filled, ic.ArrowExportRtl20Regular);
|
||||||
|
const NewWindowIcon = ic.bundleIcon(ic.WindowNew20Filled, ic.WindowNew20Regular);
|
||||||
|
const InPrivateIcon = ic.bundleIcon(ic.TabInPrivate20Filled, ic.TabInPrivate20Regular);
|
||||||
|
|
||||||
|
const handleIncognito = async () =>
|
||||||
|
{
|
||||||
|
if (await browser.extension.isAllowedIncognitoAccess())
|
||||||
|
openCollection(collection, "incognito");
|
||||||
|
else
|
||||||
|
dialog.pushPrompt({
|
||||||
|
title: i18n.t("collections.incognito_check.title"),
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
{ i18n.t(`collections.incognito_check.message.${browserLocaleKey}.p1`) }
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
{ i18n.t(`collections.incognito_check.message.${browserLocaleKey}.p2`) }
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
confirmText: i18n.t("collections.incognito_check.action"),
|
||||||
|
onConfirm: async () => import.meta.env.FIREFOX ?
|
||||||
|
await browser.runtime.openOptionsPage() :
|
||||||
|
await browser.tabs.create({
|
||||||
|
url: `chrome://extensions/?id=${browser.runtime.id}`,
|
||||||
|
active: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = (mode: "current" | "new") =>
|
||||||
|
() => openCollection(collection, mode);
|
||||||
|
|
||||||
|
const handleRestore = async () =>
|
||||||
|
{
|
||||||
|
await openCollection(collection);
|
||||||
|
removeItem(collectionIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu>
|
||||||
|
<MenuTrigger disableButtonEnhancement>
|
||||||
|
{ (triggerProps: MenuButtonProps) => defaultAction === "restore" ?
|
||||||
|
<SplitButton
|
||||||
|
appearance="subtle" icon={ <RestoreIcon /> } menuButton={ triggerProps }
|
||||||
|
primaryActionButton={ { onClick: handleRestore } }
|
||||||
|
>
|
||||||
|
{ i18n.t("collections.actions.restore") }
|
||||||
|
</SplitButton>
|
||||||
|
:
|
||||||
|
<SplitButton
|
||||||
|
appearance="subtle" icon={ <OpenIcon /> } menuButton={ triggerProps }
|
||||||
|
primaryActionButton={ { onClick: handleOpen("current") } }
|
||||||
|
>
|
||||||
|
{ i18n.t("collections.actions.open") }
|
||||||
|
</SplitButton>
|
||||||
|
}
|
||||||
|
</MenuTrigger>
|
||||||
|
|
||||||
|
<MenuPopover>
|
||||||
|
<MenuList>
|
||||||
|
{ defaultAction === "restore" ?
|
||||||
|
<MenuItem icon={ <OpenIcon /> } onClick={ handleOpen("current") }>
|
||||||
|
{ i18n.t("collections.actions.open") }
|
||||||
|
</MenuItem>
|
||||||
|
:
|
||||||
|
<MenuItem icon={ <RestoreIcon /> } onClick={ handleRestore }>
|
||||||
|
{ i18n.t("collections.actions.restore") }
|
||||||
|
</MenuItem>
|
||||||
|
}
|
||||||
|
<MenuItem icon={ <NewWindowIcon /> } onClick={ () => handleOpen("new") }>
|
||||||
|
{ i18n.t("collections.actions.new_window") }
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem icon={ <InPrivateIcon /> } onClick={ handleIncognito }>
|
||||||
|
{ i18n.t(`collections.actions.incognito.${browserLocaleKey}`) }
|
||||||
|
</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
</MenuPopover>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { CollectionItem } from "@/models/CollectionModels";
|
||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
const CollectionContext = createContext<CollectionContextType>(null!);
|
||||||
|
|
||||||
|
export default CollectionContext;
|
||||||
|
|
||||||
|
export type CollectionContextType =
|
||||||
|
{
|
||||||
|
collection: CollectionItem;
|
||||||
|
collectionIndex: number;
|
||||||
|
tabCount: number;
|
||||||
|
hasPinnedGroup: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { CloudStorageIssueType, getCollections, graphics as graphicsStorage, saveCollections } from "@/features/collectionStorage";
|
||||||
|
import useSettings from "@/hooks/useSettings";
|
||||||
|
import { CollectionItem, GraphicsStorage, GroupItem } from "@/models/CollectionModels";
|
||||||
|
import getLogger from "@/utils/getLogger";
|
||||||
|
import { onMessage } from "@/utils/messaging";
|
||||||
|
import { createContext } from "react";
|
||||||
|
import mergePinnedGroups from "../utils/mergePinnedGroups";
|
||||||
|
|
||||||
|
const logger = getLogger("CollectionsProvider");
|
||||||
|
|
||||||
|
const CollectionsContext = createContext<CollectionsContextType>(null!);
|
||||||
|
|
||||||
|
export const useCollections = () => useContext<CollectionsContextType>(CollectionsContext);
|
||||||
|
|
||||||
|
export default function CollectionsProvider({ children }: React.PropsWithChildren): React.ReactElement
|
||||||
|
{
|
||||||
|
const [collections, setCollections] = useState<CollectionItem[]>(null!);
|
||||||
|
const [cloudIssue, setCloudIssue] = useState<CloudStorageIssueType | null>(null);
|
||||||
|
const [graphics, setGraphics] = useState<GraphicsStorage>({});
|
||||||
|
const [tilesView] = useSettings("tilesView");
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
refreshCollections();
|
||||||
|
onMessage("refreshCollections", refreshCollections);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshCollections = async (): Promise<void> =>
|
||||||
|
{
|
||||||
|
const [result, issues] = await getCollections();
|
||||||
|
setCloudIssue(issues);
|
||||||
|
setCollections(result);
|
||||||
|
setGraphics(await graphicsStorage.getValue());
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStorage = async (collectionList: CollectionItem[]): Promise<void> =>
|
||||||
|
{
|
||||||
|
logger("save");
|
||||||
|
collectionList.forEach(mergePinnedGroups);
|
||||||
|
setCollections([...collectionList]);
|
||||||
|
await saveCollections(collectionList, cloudIssue === null);
|
||||||
|
setGraphics(await graphicsStorage.getValue());
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCollection = (collection: CollectionItem): void =>
|
||||||
|
{
|
||||||
|
updateStorage([collection, ...collections]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (...indices: number[]): void =>
|
||||||
|
{
|
||||||
|
if (indices.length > 2)
|
||||||
|
(collections[indices[0]].items[indices[1]] as GroupItem).items.splice(indices[2], 1);
|
||||||
|
else if (indices.length > 1)
|
||||||
|
collections[indices[0]].items.splice(indices[1], 1);
|
||||||
|
else
|
||||||
|
collections.splice(indices[0], 1);
|
||||||
|
|
||||||
|
updateStorage(collections);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCollections = (collectionList: CollectionItem[]): void =>
|
||||||
|
{
|
||||||
|
updateStorage(collectionList);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCollection = (collection: CollectionItem, index: number): void =>
|
||||||
|
{
|
||||||
|
collections[index] = collection;
|
||||||
|
updateStorage(collections);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateGroup = (group: GroupItem, collectionIndex: number, groupIndex: number): void =>
|
||||||
|
{
|
||||||
|
collections[collectionIndex].items[groupIndex] = group;
|
||||||
|
updateStorage(collections);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ungroup = (collectionIndex: number, groupIndex: number): void =>
|
||||||
|
{
|
||||||
|
const group = collections[collectionIndex].items[groupIndex] as GroupItem;
|
||||||
|
collections[collectionIndex].items.splice(groupIndex, 1, ...group.items);
|
||||||
|
updateStorage(collections);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollectionsContext.Provider
|
||||||
|
value={ {
|
||||||
|
collections, cloudIssue, graphics, tilesView: tilesView!,
|
||||||
|
refreshCollections, removeItem, ungroup,
|
||||||
|
updateCollections, updateCollection, updateGroup, addCollection
|
||||||
|
} }
|
||||||
|
>
|
||||||
|
{ children }
|
||||||
|
</CollectionsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollectionsContextType =
|
||||||
|
{
|
||||||
|
collections: CollectionItem[] | null;
|
||||||
|
cloudIssue: CloudStorageIssueType | null;
|
||||||
|
graphics: GraphicsStorage;
|
||||||
|
tilesView: boolean;
|
||||||
|
|
||||||
|
refreshCollections: () => Promise<void>;
|
||||||
|
addCollection: (collection: CollectionItem) => void;
|
||||||
|
|
||||||
|
updateCollections: (collections: CollectionItem[]) => void;
|
||||||
|
updateCollection: (collection: CollectionItem, index: number) => void;
|
||||||
|
updateGroup: (group: GroupItem, collectionIndex: number, groupIndex: number) => void;
|
||||||
|
ungroup: (collectionIndex: number, groupIndex: number) => void;
|
||||||
|
|
||||||
|
removeItem: (...indices: number[]) => void;
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { GroupItem } from "@/models/CollectionModels";
|
||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
const GroupContext = createContext<GroupContextType>(null!);
|
||||||
|
|
||||||
|
export default GroupContext;
|
||||||
|
|
||||||
|
export type GroupContextType =
|
||||||
|
{
|
||||||
|
group: GroupItem;
|
||||||
|
indices: number[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { CollectionItem, GroupItem, TabItem } from "@/models/CollectionModels";
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { Arguments } from "@dnd-kit/sortable/dist/hooks/useSortable";
|
||||||
|
|
||||||
|
export default function useDndItem(args: Arguments): DndItemHook
|
||||||
|
{
|
||||||
|
const {
|
||||||
|
setActivatorNodeRef, setNodeRef,
|
||||||
|
transform, attributes, listeners,
|
||||||
|
active, over,
|
||||||
|
isDragging,
|
||||||
|
isSorting,
|
||||||
|
isOver
|
||||||
|
} = useSortable({ transition: null, ...args });
|
||||||
|
|
||||||
|
return {
|
||||||
|
setActivatorNodeRef,
|
||||||
|
setNodeRef,
|
||||||
|
nodeProps:
|
||||||
|
{
|
||||||
|
style:
|
||||||
|
{
|
||||||
|
transform: transform ? `translate(${transform.x}px, ${transform.y}px)` : undefined
|
||||||
|
},
|
||||||
|
...attributes
|
||||||
|
},
|
||||||
|
activatorProps:
|
||||||
|
{
|
||||||
|
...listeners,
|
||||||
|
style:
|
||||||
|
{
|
||||||
|
cursor: args.disabled ? undefined : "grab"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
activeItem: active ? { ...active.data.current, id: active.id } as DndItem : null,
|
||||||
|
overItem: over ? { ...over.data.current, id: over.id } as DndItem : null,
|
||||||
|
isBeingDragged: isDragging,
|
||||||
|
isCurrentlySorting: isSorting,
|
||||||
|
isActiveOverThis: isOver
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DndItem =
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
indices: number[];
|
||||||
|
item: (TabItem | CollectionItem | GroupItem);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DndItemHook =
|
||||||
|
{
|
||||||
|
setNodeRef: (element: HTMLElement | null) => void;
|
||||||
|
setActivatorNodeRef: (element: HTMLElement | null) => void;
|
||||||
|
nodeProps: React.HTMLAttributes<HTMLElement>;
|
||||||
|
activatorProps: React.HTMLAttributes<HTMLElement>;
|
||||||
|
activeItem: DndItem | null;
|
||||||
|
overItem: DndItem | null;
|
||||||
|
isBeingDragged: boolean;
|
||||||
|
isCurrentlySorting: boolean;
|
||||||
|
isActiveOverThis: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Tabs aside</title>
|
||||||
|
|
||||||
|
<meta name="manifest.open_at_install" content="true" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||||
|
|
||||||
|
export const useStyles_CollectionListView = makeStyles({
|
||||||
|
root:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
flexFlow: "column",
|
||||||
|
gap: tokens.spacingVerticalM,
|
||||||
|
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalS}`,
|
||||||
|
overflowX: "hidden",
|
||||||
|
overflowY: "auto"
|
||||||
|
},
|
||||||
|
collectionList:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
flexFlow: "column",
|
||||||
|
gap: tokens.spacingVerticalM
|
||||||
|
},
|
||||||
|
searchBar:
|
||||||
|
{
|
||||||
|
boxShadow: tokens.shadow2
|
||||||
|
},
|
||||||
|
emptySearch:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
flexFlow: "column",
|
||||||
|
flexGrow: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: tokens.spacingVerticalS
|
||||||
|
},
|
||||||
|
empty:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
flexFlow: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: tokens.spacingVerticalS,
|
||||||
|
padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalM}`,
|
||||||
|
color: tokens.colorNeutralForeground2
|
||||||
|
},
|
||||||
|
msgBar:
|
||||||
|
{
|
||||||
|
flex: "none"
|
||||||
|
},
|
||||||
|
listView:
|
||||||
|
{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(360px, 1fr))"
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import CollectionView from "@/entrypoints/sidepanel/components/CollectionView";
|
||||||
|
import GroupView from "@/entrypoints/sidepanel/components/GroupView";
|
||||||
|
import { DndItem } from "@/entrypoints/sidepanel/hooks/useDndItem";
|
||||||
|
import CloudIssueMessages from "@/entrypoints/sidepanel/layouts/collections/messages/CloudIssueMessages";
|
||||||
|
import CtaMessage from "@/entrypoints/sidepanel/layouts/collections/messages/CtaMessage";
|
||||||
|
import filterCollections, { CollectionFilterType } from "@/entrypoints/sidepanel/utils/filterCollections";
|
||||||
|
import sortCollections from "@/entrypoints/sidepanel/utils/sortCollections";
|
||||||
|
import useSettings from "@/hooks/useSettings";
|
||||||
|
import { CollectionItem } from "@/models/CollectionModels";
|
||||||
|
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||||
|
import { rectSortingStrategy, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
|
import { Body1, Button, Caption1, mergeClasses, Subtitle2 } from "@fluentui/react-components";
|
||||||
|
import { ArrowUndo20Regular, SearchInfo24Regular, Sparkle48Regular } from "@fluentui/react-icons";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import TabView from "../../components/TabView";
|
||||||
|
import CollectionContext from "../../contexts/CollectionContext";
|
||||||
|
import { useCollections } from "../../contexts/CollectionsProvider";
|
||||||
|
import applyReorder from "../../utils/dnd/applyReorder";
|
||||||
|
import { collisionDetector } from "../../utils/dnd/collisionDetector";
|
||||||
|
import { useStyles_CollectionListView } from "./CollectionListView.styles";
|
||||||
|
import SearchBar from "./SearchBar";
|
||||||
|
import StorageCapacityIssueMessage from "./messages/StorageCapacityIssueMessage";
|
||||||
|
|
||||||
|
export default function CollectionListView(): ReactElement
|
||||||
|
{
|
||||||
|
const { tilesView, updateCollections, collections } = useCollections();
|
||||||
|
|
||||||
|
const [sortMode, setSortMode] = useSettings("sortMode");
|
||||||
|
const [query, setQuery] = useState<string>("");
|
||||||
|
const [colors, setColors] = useState<CollectionFilterType["colors"]>([]);
|
||||||
|
|
||||||
|
const [active, setActive] = useState<DndItem | null>(null);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(MouseSensor, { activationConstraint: { delay: 100, tolerance: 0 } }),
|
||||||
|
useSensor(TouchSensor, { activationConstraint: { delay: 300, tolerance: 20 } })
|
||||||
|
);
|
||||||
|
|
||||||
|
const resultList = useMemo(
|
||||||
|
() => sortCollections(filterCollections(collections, { query, colors }), sortMode),
|
||||||
|
[query, colors, sortMode, collections]
|
||||||
|
);
|
||||||
|
|
||||||
|
const cls = useStyles_CollectionListView();
|
||||||
|
|
||||||
|
const resetFilter = useCallback(() =>
|
||||||
|
{
|
||||||
|
setQuery("");
|
||||||
|
setColors([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent): void =>
|
||||||
|
{
|
||||||
|
setActive(event.active.data.current as DndItem);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (args: DragEndEvent): void =>
|
||||||
|
{
|
||||||
|
setActive(null);
|
||||||
|
const result: CollectionItem[] | null = applyReorder(resultList, args);
|
||||||
|
|
||||||
|
if (result !== null)
|
||||||
|
{
|
||||||
|
updateCollections(result);
|
||||||
|
if (sortMode !== "custom")
|
||||||
|
setSortMode("custom");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sortMode === null || collections === null)
|
||||||
|
return <></>;
|
||||||
|
|
||||||
|
if (collections.length < 1)
|
||||||
|
return (
|
||||||
|
<article className={ cls.empty }>
|
||||||
|
<Sparkle48Regular />
|
||||||
|
<Subtitle2 align="center">{ i18n.t("main.list.empty.title") }</Subtitle2>
|
||||||
|
<Caption1 align="center">{ i18n.t("main.list.empty.message") }</Caption1>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={ cls.root }>
|
||||||
|
<SearchBar
|
||||||
|
query={ query } onQueryChange={ setQuery }
|
||||||
|
filter={ colors } onFilterChange={ setColors }
|
||||||
|
sort={ sortMode } onSortChange={ setSortMode }
|
||||||
|
onReset={ resetFilter } />
|
||||||
|
|
||||||
|
<CtaMessage className={ cls.msgBar } />
|
||||||
|
<StorageCapacityIssueMessage className={ cls.msgBar } />
|
||||||
|
<CloudIssueMessages className={ cls.msgBar } />
|
||||||
|
|
||||||
|
{ resultList.length < 1 ?
|
||||||
|
<div className={ cls.emptySearch }>
|
||||||
|
<SearchInfo24Regular />
|
||||||
|
<Subtitle2>{ i18n.t("main.list.empty_search.title") }</Subtitle2>
|
||||||
|
<Body1>{ i18n.t("main.list.empty_search.message") }</Body1>
|
||||||
|
<Button appearance="subtle" icon={ <ArrowUndo20Regular /> } onClick={ resetFilter }>
|
||||||
|
{ i18n.t("common.actions.reset_filters") }
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
<section className={ mergeClasses(cls.collectionList, !tilesView && cls.listView) }>
|
||||||
|
<DndContext
|
||||||
|
sensors={ sensors }
|
||||||
|
collisionDetection={ collisionDetector(!tilesView) }
|
||||||
|
onDragStart={ handleDragStart }
|
||||||
|
onDragEnd={ handleDragEnd }
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={ resultList.map((_, index) => index.toString()) }
|
||||||
|
strategy={ tilesView ? verticalListSortingStrategy : rectSortingStrategy }
|
||||||
|
>
|
||||||
|
{ resultList.map((collection, index) =>
|
||||||
|
<CollectionView key={ index } collection={ collection } index={ index } />
|
||||||
|
) }
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
<DragOverlay dropAnimation={ null }>
|
||||||
|
{ active &&
|
||||||
|
<>
|
||||||
|
{ active.item.type === "collection" &&
|
||||||
|
<CollectionView collection={ active.item } index={ -1 } dragOverlay />
|
||||||
|
}
|
||||||
|
{ active.item.type === "group" &&
|
||||||
|
<CollectionContext.Provider
|
||||||
|
value={ {
|
||||||
|
tabCount: 0,
|
||||||
|
collectionIndex: active.indices[0],
|
||||||
|
collection: resultList[active.indices[0]],
|
||||||
|
hasPinnedGroup: true
|
||||||
|
} }
|
||||||
|
>
|
||||||
|
<GroupView group={ active.item } indices={ [-1] } dragOverlay />
|
||||||
|
</CollectionContext.Provider>
|
||||||
|
}
|
||||||
|
{ active.item.type === "tab" &&
|
||||||
|
<TabView tab={ active.item } indices={ [-1] } dragOverlay />
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useGroupColors } from "@/hooks/useGroupColors";
|
||||||
|
import * as fui from "@fluentui/react-components";
|
||||||
|
import * as ic from "@fluentui/react-icons";
|
||||||
|
import { CollectionFilterType } from "../../utils/filterCollections";
|
||||||
|
|
||||||
|
export default function FilterCollectionsButton({ value, onChange }: FilterCollectionsButtonProps): React.ReactElement
|
||||||
|
{
|
||||||
|
const cls = useStyles();
|
||||||
|
const colorCls = useGroupColors();
|
||||||
|
|
||||||
|
const ColorFilterIcon = ic.bundleIcon(ic.Color20Filled, ic.Color20Regular);
|
||||||
|
const ColorIcon = ic.bundleIcon(ic.Circle20Filled, ic.CircleHalfFill20Regular);
|
||||||
|
const NoColorIcon = ic.bundleIcon(ic.CircleOffFilled, ic.CircleOffRegular);
|
||||||
|
const AnyColorIcon = ic.bundleIcon(ic.PhotoFilter20Filled, ic.PhotoFilter20Regular);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fui.Menu
|
||||||
|
checkedValues={ !value || value.length < 1 ? { default: ["any"] } : { colors: value } }
|
||||||
|
onCheckedValueChange={ (_, e) =>
|
||||||
|
onChange?.(e.checkedItems.includes("any") ? [] : e.checkedItems as CollectionFilterType["colors"])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
<fui.Tooltip relationship="label" content={ i18n.t("main.list.searchbar.filter") }>
|
||||||
|
<fui.MenuTrigger disableButtonEnhancement>
|
||||||
|
<fui.Button appearance="subtle" icon={ <ColorFilterIcon /> } />
|
||||||
|
</fui.MenuTrigger>
|
||||||
|
</fui.Tooltip>
|
||||||
|
|
||||||
|
<fui.MenuPopover>
|
||||||
|
<fui.MenuList>
|
||||||
|
<fui.MenuItemCheckbox name="default" value="any" icon={ <AnyColorIcon /> }>
|
||||||
|
{ i18n.t("colors.any") }
|
||||||
|
</fui.MenuItemCheckbox>
|
||||||
|
<fui.MenuDivider />
|
||||||
|
<fui.MenuItemCheckbox name="colors" value="none" icon={ <NoColorIcon /> }>
|
||||||
|
{ i18n.t("colors.none") }
|
||||||
|
</fui.MenuItemCheckbox>
|
||||||
|
|
||||||
|
{ Object.keys(colorCls).map(i =>
|
||||||
|
<fui.MenuItemCheckbox
|
||||||
|
key={ i } name="colors" value={ i }
|
||||||
|
icon={
|
||||||
|
<ColorIcon
|
||||||
|
className={ fui.mergeClasses(
|
||||||
|
cls.colorIcon,
|
||||||
|
colorCls[i as chrome.tabGroups.ColorEnum]
|
||||||
|
) } />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ i18n.t(`colors.${i as chrome.tabGroups.ColorEnum}`) }
|
||||||
|
</fui.MenuItemCheckbox>
|
||||||
|
) }
|
||||||
|
</fui.MenuList>
|
||||||
|
</fui.MenuPopover>
|
||||||
|
</fui.Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FilterCollectionsButtonProps =
|
||||||
|
{
|
||||||
|
value?: CollectionFilterType["colors"];
|
||||||
|
onChange?: (value: CollectionFilterType["colors"]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useStyles = fui.makeStyles({
|
||||||
|
colorIcon:
|
||||||
|
{
|
||||||
|
color: "var(--border)"
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Button, Input, makeStyles, tokens, Tooltip } from "@fluentui/react-components";
|
||||||
|
import { ArrowUndo20Filled, ArrowUndo20Regular, bundleIcon, Search20Regular } from "@fluentui/react-icons";
|
||||||
|
import { CollectionFilterType } from "../../utils/filterCollections";
|
||||||
|
import { CollectionSortMode } from "../../utils/sortCollections";
|
||||||
|
import FilterCollectionsButton from "./FilterCollectionsButton";
|
||||||
|
import SortCollectionsButton from "./SortCollectionsButton";
|
||||||
|
|
||||||
|
export default function SearchBar(props: SearchBarProps): React.ReactElement
|
||||||
|
{
|
||||||
|
const cls = useStyles();
|
||||||
|
|
||||||
|
const ResetIcon = bundleIcon(ArrowUndo20Filled, ArrowUndo20Regular);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
className={ cls.root }
|
||||||
|
appearance="filled-lighter"
|
||||||
|
contentBefore={ <Search20Regular /> }
|
||||||
|
placeholder={ i18n.t("main.list.searchbar.title") }
|
||||||
|
value={ props.query } onChange={ (_, e) => props.onQueryChange?.(e.value) }
|
||||||
|
contentAfter={
|
||||||
|
<>
|
||||||
|
{ (props.query || (props.filter && props.filter.length > 0)) &&
|
||||||
|
<Tooltip relationship="label" content={ i18n.t("common.actions.reset_filters") }>
|
||||||
|
<Button appearance="subtle" icon={ <ResetIcon /> } onClick={ props.onReset } />
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
<FilterCollectionsButton value={ props.filter } onChange={ props.onFilterChange } />
|
||||||
|
<SortCollectionsButton value={ props.sort } onChange={ props.onSortChange } />
|
||||||
|
</>
|
||||||
|
} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SearchBarProps =
|
||||||
|
{
|
||||||
|
query?: string;
|
||||||
|
onQueryChange?: (query: string) => void;
|
||||||
|
filter?: CollectionFilterType["colors"];
|
||||||
|
onFilterChange?: (filter: CollectionFilterType["colors"]) => void;
|
||||||
|
sort?: CollectionSortMode;
|
||||||
|
onSortChange?: (sort: CollectionSortMode) => void;
|
||||||
|
onReset?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
root:
|
||||||
|
{
|
||||||
|
boxShadow: tokens.shadow2
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { CollectionSortMode } from "@/entrypoints/sidepanel/utils/sortCollections";
|
||||||
|
import { Button, Menu, MenuItemRadio, MenuList, MenuPopover, MenuTrigger, Tooltip } from "@fluentui/react-components";
|
||||||
|
import * as ic from "@fluentui/react-icons";
|
||||||
|
|
||||||
|
export default function SortCollectionsButton({ value, onChange }: SortCollectionsButtonProps): React.ReactElement
|
||||||
|
{
|
||||||
|
const ColorSortIcon = ic.bundleIcon(ic.ArrowSort20Filled, ic.ArrowSort20Regular);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
checkedValues={ { sort: value ? [value] : [] } }
|
||||||
|
onCheckedValueChange={ (_, e) => onChange?.(e.checkedItems[0] as CollectionSortMode) }
|
||||||
|
>
|
||||||
|
<Tooltip relationship="label" content={ i18n.t("main.list.searchbar.sort.title") }>
|
||||||
|
<MenuTrigger disableButtonEnhancement>
|
||||||
|
<Button appearance="subtle" icon={ <ColorSortIcon /> } />
|
||||||
|
</MenuTrigger>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<MenuPopover>
|
||||||
|
<MenuList>
|
||||||
|
{ Object.entries(sortIcons).map(([key, Icon]) =>
|
||||||
|
<MenuItemRadio key={ key } name="sort" value={ key } icon={ <Icon /> }>
|
||||||
|
{ i18n.t(`main.list.searchbar.sort.options.${key as CollectionSortMode}`) }
|
||||||
|
</MenuItemRadio>
|
||||||
|
) }
|
||||||
|
</MenuList>
|
||||||
|
</MenuPopover>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SortCollectionsButtonProps =
|
||||||
|
{
|
||||||
|
value?: CollectionSortMode | null;
|
||||||
|
onChange?: (value: CollectionSortMode) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortIcons: Record<CollectionSortMode, ic.FluentIcon> =
|
||||||
|
{
|
||||||
|
newest: ic.bundleIcon(ic.Sparkle20Filled, ic.Sparkle20Regular),
|
||||||
|
oldest: ic.bundleIcon(ic.History20Filled, ic.History20Regular),
|
||||||
|
ascending: ic.bundleIcon(ic.TextSortAscending20Filled, ic.TextSortAscending20Regular),
|
||||||
|
descending: ic.bundleIcon(ic.TextSortDescending20Filled, ic.TextSortDescending20Regular),
|
||||||
|
custom: ic.bundleIcon(ic.Star20Filled, ic.Star20Regular)
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import resolveConflict from "@/features/collectionStorage/utils/resolveConflict";
|
||||||
|
import { Button, MessageBar, MessageBarActions, MessageBarBody, MessageBarProps, MessageBarTitle } from "@fluentui/react-components";
|
||||||
|
import { ArrowUpload20Regular, CloudArrowDown20Regular, Wrench20Regular } from "@fluentui/react-icons";
|
||||||
|
import { useCollections } from "../../../contexts/CollectionsProvider";
|
||||||
|
|
||||||
|
export default function CloudIssueMessages(props: MessageBarProps): React.ReactElement
|
||||||
|
{
|
||||||
|
const { cloudIssue, refreshCollections } = useCollections();
|
||||||
|
|
||||||
|
const overrideStorageWith = async (source: "local" | "sync") =>
|
||||||
|
{
|
||||||
|
await resolveConflict(source);
|
||||||
|
await refreshCollections();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{ cloudIssue === "parse_error" &&
|
||||||
|
<MessageBar intent="error" layout="multiline" { ...props }>
|
||||||
|
<MessageBarBody>
|
||||||
|
<MessageBarTitle>{ i18n.t("parse_error_message.title") }</MessageBarTitle>
|
||||||
|
{ i18n.t("parse_error_message.message") }
|
||||||
|
</MessageBarBody>
|
||||||
|
<MessageBarActions>
|
||||||
|
<Button icon={ <Wrench20Regular /> } onClick={ () => overrideStorageWith("local") }>
|
||||||
|
{ i18n.t("parse_error_message.action") }
|
||||||
|
</Button>
|
||||||
|
</MessageBarActions>
|
||||||
|
</MessageBar>
|
||||||
|
}
|
||||||
|
|
||||||
|
{ cloudIssue === "merge_conflict" &&
|
||||||
|
<MessageBar intent="warning" layout="multiline" { ...props }>
|
||||||
|
<MessageBarBody>
|
||||||
|
<MessageBarTitle>{ i18n.t("merge_conflict_message.title") }</MessageBarTitle>
|
||||||
|
{ i18n.t("merge_conflict_message.message") }
|
||||||
|
</MessageBarBody>
|
||||||
|
<MessageBarActions>
|
||||||
|
<Button icon={ <ArrowUpload20Regular /> } onClick={ () => overrideStorageWith("local") }>
|
||||||
|
{ i18n.t("merge_conflict_message.accept_local") }
|
||||||
|
</Button>
|
||||||
|
<Button icon={ <CloudArrowDown20Regular /> } onClick={ () => overrideStorageWith("sync") }>
|
||||||
|
{ i18n.t("merge_conflict_message.accept_cloud") }
|
||||||
|
</Button>
|
||||||
|
</MessageBarActions>
|
||||||
|
</MessageBar>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { BuyMeACoffee20Regular } from "@/assets/BuyMeACoffee20";
|
||||||
|
import { buyMeACoffeeLink, storeLink } from "@/data/links";
|
||||||
|
import { useBmcStyles } from "@/hooks/useBmcStyles";
|
||||||
|
import extLink from "@/utils/extLink";
|
||||||
|
import { Button, Link, MessageBar, MessageBarActions, MessageBarBody, MessageBarProps, MessageBarTitle } from "@fluentui/react-components";
|
||||||
|
import { DismissRegular, HeartFilled } from "@fluentui/react-icons";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
|
||||||
|
export default function CtaMessage(props: MessageBarProps): ReactElement
|
||||||
|
{
|
||||||
|
const [counter, setCounter] = useState<number>(0);
|
||||||
|
const bmcCls = useBmcStyles();
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
ctaCounter.getValue().then(c =>
|
||||||
|
{
|
||||||
|
if (c >= 0)
|
||||||
|
{
|
||||||
|
setCounter(c);
|
||||||
|
ctaCounter.setValue(c + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetCounter = async (counter: number) =>
|
||||||
|
{
|
||||||
|
await ctaCounter.setValue(counter);
|
||||||
|
setCounter(counter);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (counter < 50)
|
||||||
|
return <></>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageBar layout="multiline" icon={ <HeartFilled color="red" /> } { ...props }>
|
||||||
|
<MessageBarBody>
|
||||||
|
<MessageBarTitle>{ i18n.t("cta_message.title") }</MessageBarTitle>
|
||||||
|
{ i18n.t("cta_message.message") } <Link { ...extLink(storeLink) }>{ i18n.t("cta_message.feedback") }</Link>
|
||||||
|
</MessageBarBody>
|
||||||
|
<MessageBarActions
|
||||||
|
containerAction={
|
||||||
|
<Button icon={ <DismissRegular /> } appearance="transparent" onClick={ () => resetCounter(0) } />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
as="a" { ...extLink(buyMeACoffeeLink) }
|
||||||
|
onClick={ () => resetCounter(-1) }
|
||||||
|
appearance="primary"
|
||||||
|
className={ bmcCls.button }
|
||||||
|
icon={ <BuyMeACoffee20Regular /> }
|
||||||
|
>
|
||||||
|
{ i18n.t("common.cta.sponsor") }
|
||||||
|
</Button>
|
||||||
|
</MessageBarActions>
|
||||||
|
</MessageBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctaCounter = storage.defineItem<number>("local:ctaCounter", { fallback: 0 });
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import useStorageInfo from "@/hooks/useStorageInfo";
|
||||||
|
import { MessageBar, MessageBarBody, MessageBarProps, MessageBarTitle } from "@fluentui/react-components";
|
||||||
|
|
||||||
|
export default function StorageCapacityIssueMessage(props: MessageBarProps): JSX.Element
|
||||||
|
{
|
||||||
|
const { usedStorageRatio } = useStorageInfo();
|
||||||
|
|
||||||
|
if (usedStorageRatio < 0.8)
|
||||||
|
return <></>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageBar intent="warning" layout="multiline" { ...props }>
|
||||||
|
<MessageBarBody>
|
||||||
|
<MessageBarTitle>
|
||||||
|
{ i18n.t("storage_full_message.title", [(usedStorageRatio * 100).toFixed(1)]) }
|
||||||
|
</MessageBarTitle>
|
||||||
|
{ i18n.t("storage_full_message.message") }
|
||||||
|
</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useCollections } from "@/entrypoints/sidepanel/contexts/CollectionsProvider";
|
||||||
|
import useSettings, { SettingsValue } from "@/hooks/useSettings";
|
||||||
|
import saveTabsToCollection from "@/utils/saveTabsToCollection";
|
||||||
|
import watchTabSelection from "@/utils/watchTabSelection";
|
||||||
|
import { Menu, MenuButtonProps, MenuItem, MenuList, MenuPopover, MenuTrigger, SplitButton } from "@fluentui/react-components";
|
||||||
|
import * as ic from "@fluentui/react-icons";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
|
||||||
|
export default function ActionButton(): ReactElement
|
||||||
|
{
|
||||||
|
const { addCollection } = useCollections();
|
||||||
|
const [defaultAction] = useSettings("defaultSaveAction");
|
||||||
|
const [selection, setSelection] = useState<"all" | "selected">("all");
|
||||||
|
|
||||||
|
const handleAction = async (primary: boolean) =>
|
||||||
|
{
|
||||||
|
const colection = await saveTabsToCollection(primary === (defaultAction === "set_aside"));
|
||||||
|
addCollection(colection);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
return watchTabSelection(setSelection);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (defaultAction === null)
|
||||||
|
return <div />;
|
||||||
|
|
||||||
|
const primaryActionKey: ActionsKey = `${defaultAction}.${selection}`;
|
||||||
|
const PrimaryIcon = actionIcons[primaryActionKey];
|
||||||
|
const secondaryActionKey: ActionsKey = `${defaultAction === "save" ? "set_aside" : "save"}.${selection}`;
|
||||||
|
const SecondaryIcon = actionIcons[secondaryActionKey];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu positioning="below-end">
|
||||||
|
<MenuTrigger disableButtonEnhancement>
|
||||||
|
{ (triggerProps: MenuButtonProps) => (
|
||||||
|
<SplitButton
|
||||||
|
appearance="primary"
|
||||||
|
icon={ <PrimaryIcon /> }
|
||||||
|
menuButton={ triggerProps }
|
||||||
|
primaryActionButton={ { onClick: () => handleAction(true) } }
|
||||||
|
>
|
||||||
|
{ i18n.t(`actions.${primaryActionKey}`) }
|
||||||
|
</SplitButton>
|
||||||
|
) }
|
||||||
|
</MenuTrigger>
|
||||||
|
|
||||||
|
<MenuPopover>
|
||||||
|
<MenuList>
|
||||||
|
<MenuItem icon={ <SecondaryIcon /> } onClick={ () => handleAction(false) }>
|
||||||
|
{ i18n.t(`actions.${secondaryActionKey}`) }
|
||||||
|
</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
</MenuPopover>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionIcons: Record<ActionsKey, ic.FluentIcon> =
|
||||||
|
{
|
||||||
|
"save.all": ic.bundleIcon(ic.SaveArrowRight20Filled, ic.SaveArrowRight20Regular),
|
||||||
|
"save.selected": ic.bundleIcon(ic.SaveCopy20Filled, ic.SaveCopy20Regular),
|
||||||
|
"set_aside.all": ic.bundleIcon(ic.ArrowRight20Filled, ic.ArrowRight20Regular),
|
||||||
|
"set_aside.selected": ic.bundleIcon(ic.CopyArrowRight20Filled, ic.CopyArrowRight20Regular)
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionsKey = `${SettingsValue<"defaultSaveAction">}.${"all" | "selected"}`;
|
||||||
|
|
||||||
|
export type ActionsValue =
|
||||||
|
{
|
||||||
|
label: string;
|
||||||
|
icon: ic.FluentIcon;
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useDialog } from "@/contexts/DialogProvider";
|
||||||
|
import { useCollections } from "@/entrypoints/sidepanel/contexts/CollectionsProvider";
|
||||||
|
import { Button, makeStyles, tokens, Tooltip } from "@fluentui/react-components";
|
||||||
|
import { CollectionsAddRegular } from "@fluentui/react-icons";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import EditDialog from "../../components/EditDialog";
|
||||||
|
import ActionButton from "./ActionButton";
|
||||||
|
import MoreButton from "./MoreButton";
|
||||||
|
|
||||||
|
export default function Header(): ReactElement
|
||||||
|
{
|
||||||
|
const { addCollection } = useCollections();
|
||||||
|
const dialog = useDialog();
|
||||||
|
const cls = useStyles();
|
||||||
|
|
||||||
|
const handleCreateCollection = () =>
|
||||||
|
dialog.pushCustom(
|
||||||
|
<EditDialog
|
||||||
|
type="collection"
|
||||||
|
onSave={ addCollection } />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={ cls.header }>
|
||||||
|
<ActionButton />
|
||||||
|
|
||||||
|
<div className={ cls.headerSecondary }>
|
||||||
|
<MoreButton />
|
||||||
|
<Tooltip relationship="label" content={ i18n.t("main.header.create_collection") }>
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={ <CollectionsAddRegular /> }
|
||||||
|
onClick={ handleCreateCollection } />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
header:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalS}`,
|
||||||
|
gap: tokens.spacingHorizontalS
|
||||||
|
},
|
||||||
|
headerSecondary:
|
||||||
|
{
|
||||||
|
display: "flex",
|
||||||
|
gap: tokens.spacingHorizontalXS
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { BuyMeACoffee20Filled, BuyMeACoffee20Regular } from "@/assets/BuyMeACoffee20";
|
||||||
|
import { buyMeACoffeeLink, githubLinks, storeLink } from "@/data/links";
|
||||||
|
import useSettings from "@/hooks/useSettings";
|
||||||
|
import extLink from "@/utils/extLink";
|
||||||
|
import sendNotification from "@/utils/sendNotification";
|
||||||
|
import * as fui from "@fluentui/react-components";
|
||||||
|
import * as ic from "@fluentui/react-icons";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
|
||||||
|
export default function MoreButton(): ReactElement
|
||||||
|
{
|
||||||
|
const [tilesView, setTilesView] = useSettings("tilesView");
|
||||||
|
|
||||||
|
const SettingsIcon: ic.FluentIcon = ic.bundleIcon(ic.Settings20Filled, ic.Settings20Regular);
|
||||||
|
const ViewIcon: ic.FluentIcon = ic.bundleIcon(ic.GridKanban20Filled, ic.GridKanban20Regular);
|
||||||
|
const FeedbackIcon: ic.FluentIcon = ic.bundleIcon(ic.PersonFeedback20Filled, ic.PersonFeedback20Regular);
|
||||||
|
const LearnIcon: ic.FluentIcon = ic.bundleIcon(ic.QuestionCircle20Filled, ic.QuestionCircle20Regular);
|
||||||
|
const BmcIcon: ic.FluentIcon = ic.bundleIcon(BuyMeACoffee20Filled, BuyMeACoffee20Regular);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fui.Menu
|
||||||
|
hasIcons hasCheckmarks
|
||||||
|
checkedValues={ { tilesView: tilesView ? ["true"] : [] } }
|
||||||
|
onCheckedValueChange={ (_, e) => setTilesView(e.checkedItems.length > 0) }
|
||||||
|
>
|
||||||
|
<fui.Tooltip relationship="label" content={ i18n.t("common.tooltips.more") }>
|
||||||
|
<fui.MenuTrigger disableButtonEnhancement>
|
||||||
|
<fui.Button appearance="subtle" icon={ <ic.MoreVerticalRegular /> } />
|
||||||
|
</fui.MenuTrigger>
|
||||||
|
</fui.Tooltip>
|
||||||
|
|
||||||
|
<fui.MenuPopover>
|
||||||
|
<fui.MenuList>
|
||||||
|
|
||||||
|
<fui.MenuItem icon={ <SettingsIcon /> } onClick={ () => browser.runtime.openOptionsPage() }>
|
||||||
|
{ i18n.t("options_page.title") }
|
||||||
|
</fui.MenuItem>
|
||||||
|
<fui.MenuItemCheckbox name="tilesView" value="true" icon={ <ViewIcon /> }>
|
||||||
|
{ i18n.t("main.header.menu.tiles_view") }
|
||||||
|
</fui.MenuItemCheckbox>
|
||||||
|
|
||||||
|
<fui.MenuDivider />
|
||||||
|
|
||||||
|
<fui.MenuItemLink icon={ <BmcIcon /> } { ...extLink(buyMeACoffeeLink) }>
|
||||||
|
{ i18n.t("common.cta.sponsor") }
|
||||||
|
</fui.MenuItemLink>
|
||||||
|
<fui.MenuItemLink icon={ <FeedbackIcon /> } { ...extLink(storeLink) } >
|
||||||
|
{ i18n.t("common.cta.feedback") }
|
||||||
|
</fui.MenuItemLink>
|
||||||
|
<fui.MenuItemLink icon={ <LearnIcon /> } { ...extLink(githubLinks.release) } >
|
||||||
|
{ i18n.t("main.header.menu.changelog") }
|
||||||
|
</fui.MenuItemLink>
|
||||||
|
|
||||||
|
{ import.meta.env.DEV &&
|
||||||
|
<fui.MenuGroup>
|
||||||
|
<fui.MenuGroupHeader>Dev tools</fui.MenuGroupHeader>
|
||||||
|
<fui.MenuItem
|
||||||
|
icon={ <ic.ArrowClockwise20Regular /> }
|
||||||
|
onClick={ () => document.location.reload() }
|
||||||
|
>
|
||||||
|
Reload page
|
||||||
|
</fui.MenuItem>
|
||||||
|
<fui.MenuItem
|
||||||
|
icon={ <ic.Open20Regular /> }
|
||||||
|
onClick={ () => browser.tabs.create({ url: browser.runtime.getURL("/sidepanel.html"), active: true }) }
|
||||||
|
>
|
||||||
|
Open in tab
|
||||||
|
</fui.MenuItem>
|
||||||
|
<fui.MenuItem
|
||||||
|
icon={ <ic.Alert20Regular /> }
|
||||||
|
onClick={ async () => await sendNotification({
|
||||||
|
icon: "/notification_icons/cloud_error.png",
|
||||||
|
message: "Notification message",
|
||||||
|
title: "Notification title"
|
||||||
|
}) }
|
||||||
|
>
|
||||||
|
Show test notification
|
||||||
|
</fui.MenuItem>
|
||||||
|
</fui.MenuGroup>
|
||||||
|
}
|
||||||
|
</fui.MenuList>
|
||||||
|
</fui.MenuPopover>
|
||||||
|
</fui.Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import App from "@/App.tsx";
|
||||||
|
import "@/assets/global.css";
|
||||||
|
import { useLocalMigration } from "@/features/migration";
|
||||||
|
import useWelcomeDialog from "@/features/v3welcome/hooks/useWelcomeDialog";
|
||||||
|
import { Divider, makeStyles } from "@fluentui/react-components";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import CollectionsProvider from "./contexts/CollectionsProvider";
|
||||||
|
import CollectionListView from "./layouts/collections/CollectionListView";
|
||||||
|
import Header from "./layouts/header/Header";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<App>
|
||||||
|
<MainPage />
|
||||||
|
</App>
|
||||||
|
);
|
||||||
|
|
||||||
|
document.title = i18n.t("manifest.name");
|
||||||
|
|
||||||
|
function MainPage(): React.ReactElement
|
||||||
|
{
|
||||||
|
const cls = useStyles();
|
||||||
|
|
||||||
|
useLocalMigration();
|
||||||
|
useWelcomeDialog();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollectionsProvider>
|
||||||
|
<main className={ cls.main }>
|
||||||
|
<Header />
|
||||||
|
<Divider />
|
||||||
|
<CollectionListView />
|
||||||
|
</main>
|
||||||
|
</CollectionsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
main:
|
||||||
|
{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateRows: "auto auto 1fr",
|
||||||
|
height: "100vh"
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { CollectionItem, GroupItem, TabItem } from "@/models/CollectionModels";
|
||||||
|
import { DragEndEvent } from "@dnd-kit/core";
|
||||||
|
import { arrayMove } from "@dnd-kit/sortable";
|
||||||
|
import { DndItem } from "../../hooks/useDndItem";
|
||||||
|
|
||||||
|
export default function applyReorder(collections: CollectionItem[], { over, active }: DragEndEvent): null | CollectionItem[]
|
||||||
|
{
|
||||||
|
if (!over || active.id === over.id)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const activeItem: DndItem = active.data.current as DndItem;
|
||||||
|
const overItem: DndItem = over.data.current as DndItem;
|
||||||
|
|
||||||
|
console.log("DragEnd", `active: ${active.id} ${activeItem.item.type}`, `over: ${over.id} ${overItem.item.type}`);
|
||||||
|
|
||||||
|
let newList: CollectionItem[] = [
|
||||||
|
...collections.map(collection => ({
|
||||||
|
...collection,
|
||||||
|
items: collection.items.map<TabItem | GroupItem>(item =>
|
||||||
|
item.type === "group" ?
|
||||||
|
{ ...item, items: item.items.map(tab => ({ ...tab })) } :
|
||||||
|
{ ...item }
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
if (activeItem.item.type === "collection")
|
||||||
|
{
|
||||||
|
newList = arrayMove(
|
||||||
|
newList,
|
||||||
|
activeItem.indices[0],
|
||||||
|
overItem.indices[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
return newList;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceItem: GroupItem | CollectionItem = activeItem.indices.length > 2 ?
|
||||||
|
(newList[activeItem.indices[0]].items[activeItem.indices[1]] as GroupItem) :
|
||||||
|
newList[activeItem.indices[0]];
|
||||||
|
|
||||||
|
if ((over.id as string).endsWith("_dropzone") || overItem.item.type === "collection")
|
||||||
|
{
|
||||||
|
const destItem: GroupItem | CollectionItem = overItem.indices.length > 1 ?
|
||||||
|
(newList[overItem.indices[0]].items[overItem.indices[1]] as GroupItem) :
|
||||||
|
newList[overItem.indices[0]];
|
||||||
|
|
||||||
|
destItem.items.push(activeItem.item as any);
|
||||||
|
sourceItem.items.splice(activeItem.indices[activeItem.indices.length - 1], 1);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sourceItem.items = arrayMove(
|
||||||
|
sourceItem.items,
|
||||||
|
activeItem.indices[activeItem.indices.length - 1],
|
||||||
|
overItem.indices[overItem.indices.length - 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newList;
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { ClientRect, Collision, CollisionDescriptor, CollisionDetection } from "@dnd-kit/core";
|
||||||
|
import { DndItem } from "../../hooks/useDndItem";
|
||||||
|
import { centerOfRectangle, distanceBetween, getIntersectionRatio, getMaxIntersectionRatio, getRectSideCoordinates, sortCollisionsAsc } from "./dndUtils";
|
||||||
|
|
||||||
|
export function collisionDetector(vertical?: boolean): CollisionDetection
|
||||||
|
{
|
||||||
|
return (args): Collision[] =>
|
||||||
|
{
|
||||||
|
const { collisionRect, droppableContainers, droppableRects, active, pointerCoordinates } = args;
|
||||||
|
const activeItem = active.data.current as DndItem;
|
||||||
|
|
||||||
|
if (!pointerCoordinates)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
const collisions: CollisionDescriptor[] = [];
|
||||||
|
const centerRect = centerOfRectangle(
|
||||||
|
collisionRect,
|
||||||
|
collisionRect.left,
|
||||||
|
collisionRect.top
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const droppableContainer of droppableContainers)
|
||||||
|
{
|
||||||
|
const { id, data } = droppableContainer;
|
||||||
|
const rect = droppableRects.get(id);
|
||||||
|
|
||||||
|
const droppableItem: DndItem = data.current as DndItem;
|
||||||
|
|
||||||
|
if (!rect)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
let value: number = 0;
|
||||||
|
|
||||||
|
if (activeItem.item.type === "collection")
|
||||||
|
{
|
||||||
|
if (droppableItem.item.type !== "collection")
|
||||||
|
continue;
|
||||||
|
|
||||||
|
value = distanceBetween(centerOfRectangle(rect), centerRect);
|
||||||
|
collisions.push({ id, data: { droppableContainer, value } });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intersectionRatio: number = getIntersectionRatio(rect, collisionRect);
|
||||||
|
const intersectionCoefficient: number = intersectionRatio / getMaxIntersectionRatio(rect, collisionRect);
|
||||||
|
|
||||||
|
if (droppableItem.item.type === "collection")
|
||||||
|
{
|
||||||
|
if (activeItem.indices.length === 2 && activeItem.indices[0] === droppableItem.indices[0])
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (intersectionCoefficient < 0.7 && activeItem.item.type === "tab")
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (activeItem.indices.length === 3 && activeItem.indices[0] === droppableItem.indices[0])
|
||||||
|
{
|
||||||
|
const [collectionId, groupId] = activeItem.indices;
|
||||||
|
const groupRect: ClientRect | undefined = droppableRects.get(`${collectionId}/${groupId}`);
|
||||||
|
|
||||||
|
if (!groupRect)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
value = 1 / (intersectionRatio - getIntersectionRatio(groupRect, collisionRect));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
value = 1 / intersectionRatio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (droppableItem.item.type === "group" && (id as string).endsWith("_dropzone"))
|
||||||
|
{
|
||||||
|
if (activeItem.item.type === "group")
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (
|
||||||
|
activeItem.indices.length === 3 &&
|
||||||
|
activeItem.indices[0] === droppableItem.indices[0] &&
|
||||||
|
activeItem.indices[1] === droppableItem.indices[1]
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (intersectionCoefficient < 0.5)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
value = 1 / intersectionRatio;
|
||||||
|
}
|
||||||
|
else if (activeItem.indices.length === droppableItem.indices.length)
|
||||||
|
{
|
||||||
|
if (activeItem.indices[0] !== droppableItem.indices[0])
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (activeItem.indices.length === 3 && activeItem.indices[1] !== droppableItem.indices[1])
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (droppableItem.item.type === "group" && droppableItem.item.pinned === true)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (activeItem.item.type === "tab" && droppableItem.item.type === "tab")
|
||||||
|
{
|
||||||
|
value = distanceBetween(centerOfRectangle(rect), centerRect);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const activeIndex: number = activeItem.indices[activeItem.indices.length - 1];
|
||||||
|
const droppableIndex: number = droppableItem.indices[droppableItem.indices.length - 1];
|
||||||
|
const before: boolean = activeIndex < droppableIndex;
|
||||||
|
|
||||||
|
value = distanceBetween(
|
||||||
|
getRectSideCoordinates(rect, before, vertical),
|
||||||
|
getRectSideCoordinates(collisionRect, before, vertical)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((value > 0 && value < Number.POSITIVE_INFINITY) || active.id === id)
|
||||||
|
collisions.push({ id, data: { droppableContainer, value } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return collisions.sort(sortCollisionsAsc);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { ClientRect, CollisionDescriptor } from "@dnd-kit/core";
|
||||||
|
import { Coordinates } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
|
export function getRectSideCoordinates(rect: ClientRect, before: boolean, vertical?: boolean)
|
||||||
|
{
|
||||||
|
if (before)
|
||||||
|
return vertical ? bottomsideOfRect(rect) : rightsideOfRect(rect);
|
||||||
|
|
||||||
|
return vertical ? topsideOfRect(rect) : leftsideOfRect(rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMaxIntersectionRatio(entry: ClientRect, target: ClientRect): number
|
||||||
|
{
|
||||||
|
const entrySize = entry.width * entry.height;
|
||||||
|
const targetSize = target.width * target.height;
|
||||||
|
|
||||||
|
return Math.min(targetSize / entrySize, entrySize / targetSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function topsideOfRect(rect: ClientRect): Coordinates
|
||||||
|
{
|
||||||
|
const { left, top } = rect;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: left + rect.width * 0.5,
|
||||||
|
y: top
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bottomsideOfRect(rect: ClientRect): Coordinates
|
||||||
|
{
|
||||||
|
const { left, bottom } = rect;
|
||||||
|
return {
|
||||||
|
x: left + rect.width * 0.5,
|
||||||
|
y: bottom
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rightsideOfRect(rect: ClientRect): Coordinates
|
||||||
|
{
|
||||||
|
const { right, top } = rect;
|
||||||
|
return {
|
||||||
|
x: right,
|
||||||
|
y: top + rect.height * 0.5
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function leftsideOfRect(rect: ClientRect): Coordinates
|
||||||
|
{
|
||||||
|
const { left, top } = rect;
|
||||||
|
return {
|
||||||
|
x: left,
|
||||||
|
y: top + rect.height * 0.5
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2021, Claudéric Demers
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function distanceBetween(p1: Coordinates, p2: Coordinates)
|
||||||
|
{
|
||||||
|
return Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortCollisionsAsc(
|
||||||
|
{ data: { value: a } }: CollisionDescriptor,
|
||||||
|
{ data: { value: b } }: CollisionDescriptor
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return a - b;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIntersectionRatio(entry: ClientRect, target: ClientRect): number
|
||||||
|
{
|
||||||
|
const top = Math.max(target.top, entry.top);
|
||||||
|
const left = Math.max(target.left, entry.left);
|
||||||
|
const right = Math.min(target.left + target.width, entry.left + entry.width);
|
||||||
|
const bottom = Math.min(target.top + target.height, entry.top + entry.height);
|
||||||
|
const width = right - left;
|
||||||
|
const height = bottom - top;
|
||||||
|
|
||||||
|
if (left < right && top < bottom)
|
||||||
|
{
|
||||||
|
const targetArea = target.width * target.height;
|
||||||
|
const entryArea = entry.width * entry.height;
|
||||||
|
const intersectionArea = width * height;
|
||||||
|
const intersectionRatio =
|
||||||
|
intersectionArea / (targetArea + entryArea - intersectionArea);
|
||||||
|
|
||||||
|
return Number(intersectionRatio.toFixed(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rectangles do not overlap, or overlap has an area of zero (edge/corner overlap)
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function centerOfRectangle(
|
||||||
|
rect: ClientRect,
|
||||||
|
left = rect.left,
|
||||||
|
top = rect.top
|
||||||
|
): Coordinates
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
x: left + rect.width * 0.5,
|
||||||
|
y: top + rect.height * 0.5
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { CollectionItem, TabItem } from "@/models/CollectionModels";
|
||||||
|
import sendNotification from "@/utils/sendNotification";
|
||||||
|
import { Bookmarks } from "wxt/browser";
|
||||||
|
import { getCollectionTitle } from "./getCollectionTitle";
|
||||||
|
|
||||||
|
export default async function exportCollectionToBookmarks(collection: CollectionItem)
|
||||||
|
{
|
||||||
|
const rootFolder: Bookmarks.BookmarkTreeNode = await browser.bookmarks.create({
|
||||||
|
title: getCollectionTitle(collection)
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < collection.items.length; i++)
|
||||||
|
{
|
||||||
|
const item = collection.items[i];
|
||||||
|
|
||||||
|
if (item.type === "tab")
|
||||||
|
{
|
||||||
|
await createTabBookmark(item, rootFolder.id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const groupFolder = await browser.bookmarks.create({
|
||||||
|
parentId: rootFolder.id,
|
||||||
|
title: item.pinned
|
||||||
|
? `📌 ${i18n.t("groups.pinned")}` :
|
||||||
|
(item.title?.trim() || `${i18n.t("groups.title")} ${i}`)
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const tab of item.items)
|
||||||
|
await createTabBookmark(tab, groupFolder.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendNotification({
|
||||||
|
title: i18n.t("notifications.bookmark_saved.title"),
|
||||||
|
message: i18n.t("notifications.bookmark_saved.message"),
|
||||||
|
icon: "/notification_icons/bookmark_add.png"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTabBookmark(tab: TabItem, parentId: string): Promise<void>
|
||||||
|
{
|
||||||
|
await browser.bookmarks.create({
|
||||||
|
parentId,
|
||||||
|
title: tab.title?.trim() || tab.url,
|
||||||
|
url: tab.url
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { getCollectionTitle } from "@/entrypoints/sidepanel/utils/getCollectionTitle";
|
||||||
|
import { CollectionItem, TabItem } from "@/models/CollectionModels";
|
||||||
|
|
||||||
|
export default function filterCollections(
|
||||||
|
collections: CollectionItem[] | null,
|
||||||
|
filter: CollectionFilterType
|
||||||
|
): CollectionItem[]
|
||||||
|
{
|
||||||
|
if (!collections || collections.length < 1)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
if (!filter.query && filter.colors.length < 1)
|
||||||
|
return collections;
|
||||||
|
|
||||||
|
const query: string = filter.query.toLocaleLowerCase();
|
||||||
|
|
||||||
|
return collections.filter(collection =>
|
||||||
|
{
|
||||||
|
let querySatisfied: boolean = query.length < 1 ||
|
||||||
|
getCollectionTitle(collection).toLocaleLowerCase().includes(query);
|
||||||
|
let colorSatisfied: boolean = filter.colors.length < 1 ||
|
||||||
|
filter.colors.includes(collection.color ?? "none");
|
||||||
|
|
||||||
|
if (querySatisfied && colorSatisfied)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
function probeTab(tab: TabItem, query: string): boolean
|
||||||
|
{
|
||||||
|
return tab.title?.toLocaleLowerCase().includes(query) || tab.url.toLocaleLowerCase().includes(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of collection.items)
|
||||||
|
{
|
||||||
|
if (item.type === "tab" && !querySatisfied)
|
||||||
|
{
|
||||||
|
querySatisfied = probeTab(item, query);
|
||||||
|
}
|
||||||
|
else if (item.type === "group")
|
||||||
|
{
|
||||||
|
if (item.pinned !== true)
|
||||||
|
{
|
||||||
|
if (!querySatisfied)
|
||||||
|
querySatisfied = (item.title?.toLocaleLowerCase() ?? "").includes(query);
|
||||||
|
|
||||||
|
if (!colorSatisfied)
|
||||||
|
colorSatisfied = filter.colors.includes(item.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!querySatisfied)
|
||||||
|
querySatisfied = item.items.some(i => probeTab(i, query));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (querySatisfied && colorSatisfied)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollectionFilterType =
|
||||||
|
{
|
||||||
|
query: string;
|
||||||
|
colors: (chrome.tabGroups.ColorEnum | "none")[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { CollectionItem } from "@/models/CollectionModels";
|
||||||
|
|
||||||
|
export function getCollectionTitle(collection?: CollectionItem): string
|
||||||
|
{
|
||||||
|
return collection?.title
|
||||||
|
|| new Date(collection?.timestamp ?? Date.now())
|
||||||
|
.toLocaleDateString(browser.i18n.getUILanguage(), { year: "numeric", month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { TabItem } from "@/models/CollectionModels";
|
||||||
|
import { Tabs } from "wxt/browser";
|
||||||
|
|
||||||
|
export default async function getSelectedTabs(): Promise<TabItem[]>
|
||||||
|
{
|
||||||
|
const tabs: Tabs.Tab[] = await browser.tabs.query({ currentWindow: true, highlighted: true });
|
||||||
|
return tabs.filter(i => i.url).map(i => ({ type: "tab", url: i.url!, title: i.title }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { CollectionItem, TabItem } from "@/models/CollectionModels";
|
||||||
|
|
||||||
|
export default function mergePinnedGroups(collection: CollectionItem): void
|
||||||
|
{
|
||||||
|
const pinnedItems: TabItem[] = [];
|
||||||
|
const otherItems: CollectionItem["items"] = [];
|
||||||
|
let pinExists: boolean = false;
|
||||||
|
|
||||||
|
collection.items.forEach(item =>
|
||||||
|
{
|
||||||
|
if (item.type === "group" && item.pinned === true)
|
||||||
|
{
|
||||||
|
pinExists = true;
|
||||||
|
pinnedItems.push(...item.items);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
otherItems.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pinnedItems.length > 0 || pinExists)
|
||||||
|
collection.items = [
|
||||||
|
{ type: "group", pinned: true, items: pinnedItems },
|
||||||
|
...otherItems
|
||||||
|
];
|
||||||
|
else
|
||||||
|
collection.items = otherItems;
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { getCollectionTitle } from "@/entrypoints/sidepanel/utils/getCollectionTitle";
|
||||||
|
import { CollectionItem, GroupItem, TabItem } from "@/models/CollectionModels";
|
||||||
|
import { settings } from "@/utils/settings";
|
||||||
|
import { Tabs, Windows } from "wxt/browser";
|
||||||
|
|
||||||
|
export async function openCollection(collection: CollectionItem, targetWindow?: "current" | "new" | "incognito"): Promise<void>
|
||||||
|
{
|
||||||
|
if (targetWindow === "incognito" && !(await browser.extension.isAllowedIncognitoAccess()))
|
||||||
|
throw new Error("The extension doesn't have incognito permission");
|
||||||
|
|
||||||
|
const discard: boolean = await settings.dismissOnLoad.getValue();
|
||||||
|
|
||||||
|
await manageWindow(
|
||||||
|
async windowId =>
|
||||||
|
{
|
||||||
|
if (collection.items.some(i => i.type === "group"))
|
||||||
|
// Open tabs as regular, open groups as groups
|
||||||
|
await Promise.all(collection.items.map(async i =>
|
||||||
|
{
|
||||||
|
if (i.type === "tab")
|
||||||
|
await createTab(i.url, windowId, discard);
|
||||||
|
else
|
||||||
|
await createGroup(i, windowId, discard);
|
||||||
|
}));
|
||||||
|
|
||||||
|
else if (collection.color)
|
||||||
|
// Open collection as one big group
|
||||||
|
await createGroup({
|
||||||
|
type: "group",
|
||||||
|
color: collection.color,
|
||||||
|
title: getCollectionTitle(collection),
|
||||||
|
items: collection.items as TabItem[]
|
||||||
|
}, windowId);
|
||||||
|
|
||||||
|
else
|
||||||
|
// Open collection tabs as is
|
||||||
|
await Promise.all(collection.items.map(async i =>
|
||||||
|
await createTab((i as TabItem).url, windowId, discard)
|
||||||
|
));
|
||||||
|
},
|
||||||
|
(!targetWindow || targetWindow === "current") ?
|
||||||
|
undefined :
|
||||||
|
{ incognito: targetWindow === "incognito" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openGroup(group: GroupItem, newWindow: boolean = false): Promise<void>
|
||||||
|
{
|
||||||
|
await manageWindow(
|
||||||
|
windowId => createGroup(group, windowId),
|
||||||
|
newWindow ? {} : undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGroup(group: GroupItem, windowId: number, discard?: boolean): Promise<void>
|
||||||
|
{
|
||||||
|
discard ??= await settings.dismissOnLoad.getValue();
|
||||||
|
const tabIds: number[] = await Promise.all(group.items.map(async i =>
|
||||||
|
(await createTab(i.url, windowId, discard, group.pinned)).id!
|
||||||
|
));
|
||||||
|
|
||||||
|
// "Pinned" group is technically not a group, so not much else to do here
|
||||||
|
// and Firefox doesn't even support tab groups
|
||||||
|
if (group.pinned === true || import.meta.env.FIREFOX)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const groupId: number = await chrome.tabs.group({
|
||||||
|
tabIds, createProperties: {
|
||||||
|
windowId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await chrome.tabGroups.update(groupId, {
|
||||||
|
title: group.title,
|
||||||
|
color: group.color
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function manageWindow(handle: (windowId: number) => Promise<void>, windowProps?: Windows.CreateCreateDataType): Promise<void>
|
||||||
|
{
|
||||||
|
const currentWindow: Windows.Window = windowProps ?
|
||||||
|
await browser.windows.create({ url: "about:blank", focused: true, ...windowProps }) :
|
||||||
|
await browser.windows.getCurrent();
|
||||||
|
const windowId: number = currentWindow.id!;
|
||||||
|
|
||||||
|
await handle(windowId);
|
||||||
|
|
||||||
|
if (windowProps)
|
||||||
|
// Close "about:blank" tab
|
||||||
|
await browser.tabs.remove(currentWindow.tabs![0].id!);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTab(url: string, windowId: number, discard: boolean, pinned?: boolean): Promise<Tabs.Tab>
|
||||||
|
{
|
||||||
|
const tab = await browser.tabs.create({ url, windowId: windowId, active: false, pinned });
|
||||||
|
|
||||||
|
if (discard)
|
||||||
|
discardOnLoad(tab.id!);
|
||||||
|
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardOnLoad(tabId: number): void
|
||||||
|
{
|
||||||
|
const handleTabUpdated = (id: number, _: any, tab: Tabs.Tab) =>
|
||||||
|
{
|
||||||
|
if (id !== tabId || !tab.url)
|
||||||
|
return;
|
||||||
|
|
||||||
|
browser.tabs.onUpdated.removeListener(handleTabUpdated);
|
||||||
|
|
||||||
|
if (!tab.active)
|
||||||
|
browser.tabs.discard(tabId);
|
||||||
|
};
|
||||||
|
|
||||||
|
browser.tabs.onUpdated.addListener(handleTabUpdated);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { getCollectionTitle } from "@/entrypoints/sidepanel/utils/getCollectionTitle";
|
||||||
|
import { CollectionItem } from "@/models/CollectionModels";
|
||||||
|
|
||||||
|
export default function sortCollections(
|
||||||
|
collections: CollectionItem[],
|
||||||
|
mode?: CollectionSortMode | null
|
||||||
|
): CollectionItem[]
|
||||||
|
{
|
||||||
|
return sorters[mode ?? "custom"]([...collections]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollectionSortMode = "ascending" | "descending" | "newest" | "oldest" | "custom";
|
||||||
|
|
||||||
|
const sorters: Record<CollectionSortMode, CollectionSorter> =
|
||||||
|
{
|
||||||
|
ascending: i => i.sort((a, b) => getCollectionTitle(a).localeCompare(getCollectionTitle(b))),
|
||||||
|
descending: i => i.sort((a, b) => getCollectionTitle(b).localeCompare(getCollectionTitle(a))),
|
||||||
|
newest: i => i.sort((a, b) => b.timestamp - a.timestamp),
|
||||||
|
oldest: i => i.sort((a, b) => a.timestamp - b.timestamp),
|
||||||
|
custom: i => i
|
||||||
|
};
|
||||||
|
|
||||||
|
type CollectionSorter = (collections: CollectionItem[]) => CollectionItem[];
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import css from "@eslint/css";
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import json from "@eslint/json";
|
||||||
|
import stylistic from "@stylistic/eslint-plugin";
|
||||||
|
import pluginReact from "eslint-plugin-react";
|
||||||
|
import { defineConfig } from "eslint/config";
|
||||||
|
import globals from "globals";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
{
|
||||||
|
ignores: [".wxt/", ".output/"]
|
||||||
|
},
|
||||||
|
{ files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], plugins: { js }, extends: ["js/recommended"] },
|
||||||
|
{ files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], languageOptions: { globals: globals.browser } },
|
||||||
|
{ files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], extends: [tseslint.configs.recommended] },
|
||||||
|
{ files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], extends: [pluginReact.configs.flat.recommended] },
|
||||||
|
{ files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], extends: [stylistic.configs.recommended] },
|
||||||
|
{ files: ["**/*.css"], plugins: { css }, language: "css/css", extends: ["css/recommended"] },
|
||||||
|
{
|
||||||
|
files: ["**/*.{jsonc,json}"],
|
||||||
|
plugins: { json },
|
||||||
|
language: "json/jsonc",
|
||||||
|
extends: ["json/recommended"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.json"],
|
||||||
|
ignores: [".devcontainer/devcontainer.json"],
|
||||||
|
plugins: { json },
|
||||||
|
language: "json/json",
|
||||||
|
extends: ["json/recommended"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
|
||||||
|
settings:
|
||||||
|
{
|
||||||
|
react:
|
||||||
|
{
|
||||||
|
version: "detect"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
|
||||||
|
plugins: {
|
||||||
|
"@stylistic": stylistic
|
||||||
|
},
|
||||||
|
rules:
|
||||||
|
{
|
||||||
|
"@stylistic/semi": ["error", "always"],
|
||||||
|
"@stylistic/block-spacing": ["warn", "always"],
|
||||||
|
"@stylistic/arrow-spacing": ["warn", { before: true, after: true }],
|
||||||
|
"@stylistic/indent": ["warn", "tab"],
|
||||||
|
"@stylistic/quotes": ["error", "double"],
|
||||||
|
"@stylistic/comma-spacing": ["warn"],
|
||||||
|
"@stylistic/comma-dangle": ["warn", "never"],
|
||||||
|
"@stylistic/no-tabs": ["warn", { allowIndentationTabs: true }],
|
||||||
|
"@stylistic/brace-style": ["warn", "allman", { allowSingleLine: true }],
|
||||||
|
"@stylistic/member-delimiter-style": ["error", { multiline: { delimiter: "semi", requireLast: true }, singleline: { delimiter: "semi", requireLast: true } }],
|
||||||
|
"@stylistic/jsx-curly-spacing": ["warn", { when: "always", children: true, attributes: true }],
|
||||||
|
"react/react-in-jsx-scope": ["off"],
|
||||||
|
"@stylistic/jsx-indent-props": ["warn", "tab"],
|
||||||
|
"@stylistic/jsx-max-props-per-line": ["off"],
|
||||||
|
"@stylistic/indent-binary-ops": ["warn", "tab"],
|
||||||
|
"@stylistic/no-multiple-empty-lines": ["warn"],
|
||||||
|
"@stylistic/operator-linebreak": ["off"],
|
||||||
|
"@stylistic/jsx-wrap-multilines": ["off"],
|
||||||
|
"@typescript-eslint/no-explicit-any": ["off"],
|
||||||
|
"@stylistic/jsx-curly-newline": ["off"],
|
||||||
|
"@stylistic/jsx-tag-spacing":
|
||||||
|
[
|
||||||
|
"warn",
|
||||||
|
{ closingSlash: "never", beforeSelfClosing: "always", afterOpening: "never" }
|
||||||
|
],
|
||||||
|
"@stylistic/jsx-closing-bracket-location":
|
||||||
|
[
|
||||||
|
"warn",
|
||||||
|
{ nonEmpty: "tag-aligned", selfClosing: "after-props" }
|
||||||
|
],
|
||||||
|
"@stylistic/jsx-first-prop-new-line": ["warn", "multiline"],
|
||||||
|
"@stylistic/jsx-one-expression-per-line": ["off"],
|
||||||
|
"@stylistic/jsx-closing-tag-location": ["warn"],
|
||||||
|
"@stylistic/arrow-parens": ["off"],
|
||||||
|
"@stylistic/quote-props": ["off"],
|
||||||
|
"@stylistic/multiline-ternary": ["warn"],
|
||||||
|
"@stylistic/no-trailing-spaces": ["warn"],
|
||||||
|
"@stylistic/no-mixed-spaces-and-tabs": ["warn"],
|
||||||
|
"@typescript-eslint/no-unused-vars": ["warn"],
|
||||||
|
"prefer-const": ["warn"],
|
||||||
|
"@stylistic/padded-blocks": ["warn"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.css"],
|
||||||
|
plugins: { css },
|
||||||
|
rules:
|
||||||
|
{
|
||||||
|
"css/use-baseline": ["off"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { collectionStorage } from "./utils/collectionStorage";
|
||||||
|
export * from "./utils/getCollections";
|
||||||
|
|
||||||
|
export { default as getCollections } from "./utils/getCollections";
|
||||||
|
export { default as resoveConflict } from "./utils/resolveConflict";
|
||||||
|
export { default as saveCollections } from "./utils/saveCollections";
|
||||||
|
|
||||||
|
export const collectionCount = collectionStorage.count;
|
||||||
|
export const graphics = collectionStorage.graphics;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { CollectionItem, GraphicsStorage } from "@/models/CollectionModels";
|
||||||
|
|
||||||
|
export const collectionStorage =
|
||||||
|
{
|
||||||
|
chunkCount: storage.defineItem<number>("sync:chunkCount", { fallback: 0 }),
|
||||||
|
syncLastUpdated: storage.defineItem<number>("sync:lastUpdated", { fallback: 0 }),
|
||||||
|
localLastUpdated: storage.defineItem<number>("local:lastUpdated", { fallback: 0 }),
|
||||||
|
localCollections: storage.defineItem<CollectionItem[]>("local:collections", { fallback: [] }),
|
||||||
|
count: storage.defineItem<number>("local:count", { fallback: 0 }),
|
||||||
|
graphics: storage.defineItem<GraphicsStorage>("local:graphics", { fallback: {} }),
|
||||||
|
maxChunkCount: 12
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { collectionStorage } from "./collectionStorage";
|
||||||
|
|
||||||
|
export default function getChunkKeys(start: number = 0, end: number = collectionStorage.maxChunkCount): string[]
|
||||||
|
{
|
||||||
|
return Array.from({ length: end - start }, (_, i) => "c" + (i + start));
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { CollectionItem } from "@/models/CollectionModels";
|
||||||
|
import { collectionStorage } from "./collectionStorage";
|
||||||
|
import getCollectionsFromCloud from "./getCollectionsFromCloud";
|
||||||
|
import getCollectionsFromLocal from "./getCollectionsFromLocal";
|
||||||
|
import saveCollectionsToLocal from "./saveCollectionsToLocal";
|
||||||
|
import getLogger from "@/utils/getLogger";
|
||||||
|
|
||||||
|
const logger = getLogger("getCollections");
|
||||||
|
|
||||||
|
export default async function getCollections(): Promise<[CollectionItem[], CloudStorageIssueType | null]>
|
||||||
|
{
|
||||||
|
const lastUpdatedLocal: number = await collectionStorage.localLastUpdated.getValue();
|
||||||
|
const lastUpdatedSync: number = await collectionStorage.syncLastUpdated.getValue();
|
||||||
|
|
||||||
|
if (lastUpdatedLocal === lastUpdatedSync)
|
||||||
|
return [await getCollectionsFromLocal(), null];
|
||||||
|
|
||||||
|
if (lastUpdatedLocal > lastUpdatedSync)
|
||||||
|
return [await getCollectionsFromLocal(), "merge_conflict"];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const collections: CollectionItem[] = await getCollectionsFromCloud();
|
||||||
|
await saveCollectionsToLocal(collections, lastUpdatedSync);
|
||||||
|
|
||||||
|
return [collections, null];
|
||||||
|
}
|
||||||
|
catch (ex)
|
||||||
|
{
|
||||||
|
logger("Failed to get cloud storage");
|
||||||
|
console.error(ex);
|
||||||
|
return [await getCollectionsFromLocal(), "parse_error"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CloudStorageIssueType = "parse_error" | "merge_conflict";
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { CollectionItem } from "@/models/CollectionModels";
|
||||||
|
import { decompress } from "lzutf8";
|
||||||
|
import { collectionStorage } from "./collectionStorage";
|
||||||
|
import getChunkKeys from "./getChunkKeys";
|
||||||
|
import parseCollections from "./parseCollections";
|
||||||
|
|
||||||
|
export default async function getCollectionsFromCloud(): Promise<CollectionItem[]>
|
||||||
|
{
|
||||||
|
const chunkCount: number = await collectionStorage.chunkCount.getValue();
|
||||||
|
|
||||||
|
if (chunkCount < 1)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
const chunks: Record<string, string> =
|
||||||
|
await browser.storage.sync.get(getChunkKeys(0, chunkCount)) as Record<string, string>;
|
||||||
|
|
||||||
|
const data: string = decompress(Object.values(chunks).join(), { inputEncoding: "StorageBinaryString" });
|
||||||
|
|
||||||
|
return parseCollections(data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { CollectionItem } from "@/models/CollectionModels";
|
||||||
|
import { collectionStorage } from "./collectionStorage";
|
||||||
|
|
||||||
|
export default async function getCollectionsFromLocal(): Promise<CollectionItem[]>
|
||||||
|
{
|
||||||
|
return await collectionStorage.localCollections.getValue();
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { CollectionItem, GroupItem, TabItem } from "@/models/CollectionModels";
|
||||||
|
|
||||||
|
export default function parseCollections(data: string): CollectionItem[]
|
||||||
|
{
|
||||||
|
if (!data)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
const collections: CollectionItem[] = [];
|
||||||
|
const lines: string[] = data.split("\n");
|
||||||
|
|
||||||
|
for (const line of lines)
|
||||||
|
{
|
||||||
|
if (line.startsWith("c"))
|
||||||
|
{
|
||||||
|
const collection: CollectionItem = parseCollection(line);
|
||||||
|
collections.push(collection);
|
||||||
|
}
|
||||||
|
else if (line.startsWith("\tg"))
|
||||||
|
{
|
||||||
|
const group: GroupItem = parseGroup(line);
|
||||||
|
collections[collections.length - 1].items.push(group);
|
||||||
|
}
|
||||||
|
else if (line.startsWith("\t\tt"))
|
||||||
|
{
|
||||||
|
const tab: TabItem = parseTab(line);
|
||||||
|
|
||||||
|
const collectionIndex: number = collections.length - 1;
|
||||||
|
const groupIndex: number = collections[collectionIndex].items.length - 1;
|
||||||
|
|
||||||
|
(collections[collectionIndex].items[groupIndex] as GroupItem).items.push(tab);
|
||||||
|
}
|
||||||
|
else if (line.startsWith("\tt"))
|
||||||
|
{
|
||||||
|
const tab: TabItem = parseTab(line);
|
||||||
|
collections[collections.length - 1].items.push(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return collections;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCollection(data: string): CollectionItem
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
type: "collection",
|
||||||
|
timestamp: parseInt(data.match(/(?<=^c)\d+/)!.toString()),
|
||||||
|
color: data.match(/(?<=^c\d+\/)[a-z]+/)?.toString() as chrome.tabGroups.ColorEnum,
|
||||||
|
title: data.match(/(?<=^c[\da-z/]*\|).*/)?.toString(),
|
||||||
|
items: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGroup(data: string): GroupItem
|
||||||
|
{
|
||||||
|
const isPinned: boolean = data.match(/^\tg\/p$/) !== null;
|
||||||
|
|
||||||
|
if (isPinned)
|
||||||
|
return {
|
||||||
|
type: "group",
|
||||||
|
pinned: true,
|
||||||
|
items: []
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "group",
|
||||||
|
pinned: false,
|
||||||
|
color: data.match(/(?<=^\tg\/)[a-z]+/)?.toString() as chrome.tabGroups.ColorEnum,
|
||||||
|
title: data.match(/(?<=^\tg\/[a-z]+\|).*$/)?.toString(),
|
||||||
|
items: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTab(data: string): TabItem
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
type: "tab",
|
||||||
|
url: data.match(/(?<=^(\t){1,2}t\|).*(?=\|)/)!.toString(),
|
||||||
|
title: data.match(/(?<=^(\t){1,2}t\|.*\|).*$/)?.toString()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { CollectionItem } from "@/models/CollectionModels";
|
||||||
|
import getLogger from "@/utils/getLogger";
|
||||||
|
import { collectionStorage } from "./collectionStorage";
|
||||||
|
import getCollectionsFromCloud from "./getCollectionsFromCloud";
|
||||||
|
import getCollectionsFromLocal from "./getCollectionsFromLocal";
|
||||||
|
import saveCollectionsToCloud from "./saveCollectionsToCloud";
|
||||||
|
import saveCollectionsToLocal from "./saveCollectionsToLocal";
|
||||||
|
|
||||||
|
const logger = getLogger("resolveConflict");
|
||||||
|
|
||||||
|
export default function resolveConflict(acceptSource: "local" | "sync"): Promise<void>
|
||||||
|
{
|
||||||
|
if (acceptSource === "local")
|
||||||
|
return replaceCloudWithLocal();
|
||||||
|
|
||||||
|
return replaceLocalWithCloud();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceCloudWithLocal(): Promise<void>
|
||||||
|
{
|
||||||
|
const collections: CollectionItem[] = await getCollectionsFromLocal();
|
||||||
|
const lastUpdated: number = await collectionStorage.localLastUpdated.getValue();
|
||||||
|
|
||||||
|
await saveCollectionsToCloud(collections, lastUpdated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceLocalWithCloud(): Promise<void>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const collections: CollectionItem[] = await getCollectionsFromCloud();
|
||||||
|
const lastUpdated: number = await collectionStorage.syncLastUpdated.getValue();
|
||||||
|
|
||||||
|
await saveCollectionsToLocal(collections, lastUpdated);
|
||||||
|
}
|
||||||
|
catch (ex)
|
||||||
|
{
|
||||||
|
logger("Failed to get cloud storage");
|
||||||
|
console.error(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { CollectionItem, GraphicsStorage } from "@/models/CollectionModels";
|
||||||
|
import getLogger from "@/utils/getLogger";
|
||||||
|
import sendNotification from "@/utils/sendNotification";
|
||||||
|
import saveCollectionsToCloud from "./saveCollectionsToCloud";
|
||||||
|
import saveCollectionsToLocal from "./saveCollectionsToLocal";
|
||||||
|
import updateGraphics from "./updateGraphics";
|
||||||
|
|
||||||
|
const logger = getLogger("saveCollections");
|
||||||
|
|
||||||
|
export default async function saveCollections(
|
||||||
|
collections: CollectionItem[],
|
||||||
|
updateCloud: boolean = true,
|
||||||
|
graphicsCache?: GraphicsStorage
|
||||||
|
): Promise<void>
|
||||||
|
{
|
||||||
|
const timestamp: number = Date.now();
|
||||||
|
await saveCollectionsToLocal(collections, timestamp);
|
||||||
|
|
||||||
|
if (updateCloud)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await saveCollectionsToCloud(collections, timestamp);
|
||||||
|
}
|
||||||
|
catch (ex)
|
||||||
|
{
|
||||||
|
logger("Failed to save cloud storage");
|
||||||
|
console.error(ex);
|
||||||
|
|
||||||
|
if ((ex as Error).message.includes("MAX_WRITE_OPERATIONS_PER_MINUTE"))
|
||||||
|
await sendNotification({
|
||||||
|
title: i18n.t("notifications.error_quota_exceeded.title"),
|
||||||
|
message: i18n.t("notifications.error_quota_exceeded.message"),
|
||||||
|
icon: "/notification_icons/cloud_error.png"
|
||||||
|
});
|
||||||
|
else
|
||||||
|
await sendNotification({
|
||||||
|
title: i18n.t("notifications.error_storage_full.title"),
|
||||||
|
message: i18n.t("notifications.error_storage_full.message"),
|
||||||
|
icon: "/notification_icons/cloud_error.png"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateGraphics(collections, graphicsCache);
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { CollectionItem } from "@/models/CollectionModels";
|
||||||
|
import { compress } from "lzutf8";
|
||||||
|
import { WxtStorageItem } from "wxt/storage";
|
||||||
|
import { collectionStorage } from "./collectionStorage";
|
||||||
|
import getChunkKeys from "./getChunkKeys";
|
||||||
|
import serializeCollections from "./serializeCollections";
|
||||||
|
|
||||||
|
export default async function saveCollectionsToCloud(collections: CollectionItem[], timestamp: number): Promise<void>
|
||||||
|
{
|
||||||
|
if (!collections || collections.length < 1)
|
||||||
|
{
|
||||||
|
await collectionStorage.chunkCount.setValue(0);
|
||||||
|
await browser.storage.sync.remove(getChunkKeys());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: string = compress(serializeCollections(collections), { outputEncoding: "StorageBinaryString" });
|
||||||
|
const chunks: string[] = splitIntoChunks(data);
|
||||||
|
|
||||||
|
if (chunks.length > collectionStorage.maxChunkCount)
|
||||||
|
throw new Error("Data is too large to be stored in sync storage.");
|
||||||
|
|
||||||
|
// Since there's a limit for cloud write operations, we need to write all chunks in one go.
|
||||||
|
const newRecords: Record<string, string | number> =
|
||||||
|
{
|
||||||
|
[getStorageKey(collectionStorage.chunkCount)]: chunks.length,
|
||||||
|
[getStorageKey(collectionStorage.syncLastUpdated)]: timestamp
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < chunks.length; i++)
|
||||||
|
newRecords[`c${i}`] = chunks[i];
|
||||||
|
|
||||||
|
await browser.storage.sync.set(newRecords);
|
||||||
|
|
||||||
|
if (chunks.length < collectionStorage.maxChunkCount)
|
||||||
|
await browser.storage.sync.remove(getChunkKeys(chunks.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitIntoChunks(data: string): string[]
|
||||||
|
{
|
||||||
|
// QUOTA_BYTES_PER_ITEM includes length of key name, length of content and 2 more bytes (for unknown reason).
|
||||||
|
const chunkKey: string = getChunkKeys(collectionStorage.maxChunkCount - 1)[0];
|
||||||
|
const chunkSize = (chrome.storage.sync.QUOTA_BYTES_PER_ITEM ?? 8192) - chunkKey.length - 2;
|
||||||
|
const chunks: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i += chunkSize)
|
||||||
|
chunks.push(data.slice(i, i + chunkSize));
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStorageKey(storageItem: WxtStorageItem<any, any>): string
|
||||||
|
{
|
||||||
|
return storageItem.key.split(":")[1];
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { CollectionItem } from "@/models/CollectionModels";
|
||||||
|
import { collectionStorage } from "./collectionStorage";
|
||||||
|
|
||||||
|
export default async function saveCollectionsToLocal(collections: CollectionItem[], timestamp: number): Promise<void>
|
||||||
|
{
|
||||||
|
await collectionStorage.localCollections.setValue(collections);
|
||||||
|
await collectionStorage.count.setValue(collections.length);
|
||||||
|
await collectionStorage.localLastUpdated.setValue(timestamp);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { CollectionItem, GroupItem, TabItem } from "@/models/CollectionModels";
|
||||||
|
|
||||||
|
export default function serializeCollections(collections: CollectionItem[]): string
|
||||||
|
{
|
||||||
|
let data: string = "";
|
||||||
|
|
||||||
|
for (const collection of collections)
|
||||||
|
{
|
||||||
|
data += getCollectionString(collection);
|
||||||
|
|
||||||
|
for (const item of collection.items)
|
||||||
|
{
|
||||||
|
if (item.type === "group")
|
||||||
|
{
|
||||||
|
data += getGroupString(item);
|
||||||
|
|
||||||
|
for (const tab of item.items)
|
||||||
|
data += `\t${getTabString(tab)}`;
|
||||||
|
}
|
||||||
|
else if (item.type === "tab")
|
||||||
|
data += getTabString(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCollectionString(collection: CollectionItem): string
|
||||||
|
{
|
||||||
|
let data: string = `c${collection.timestamp}`;
|
||||||
|
|
||||||
|
if (collection.color)
|
||||||
|
data += `/${collection.color}`;
|
||||||
|
|
||||||
|
if (collection.title)
|
||||||
|
data += `|${collection.title}`;
|
||||||
|
|
||||||
|
data += "\n";
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupString(group: GroupItem): string
|
||||||
|
{
|
||||||
|
let data: string = "\tg";
|
||||||
|
|
||||||
|
if (group.pinned === true)
|
||||||
|
data += "/p";
|
||||||
|
else
|
||||||
|
{
|
||||||
|
data += `/${group.color}`;
|
||||||
|
|
||||||
|
if (group.title)
|
||||||
|
data += `|${group.title}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
data += "\n";
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTabString(tab: TabItem): string
|
||||||
|
{
|
||||||
|
let data: string = "\tt";
|
||||||
|
|
||||||
|
data += `|${tab.url}|`;
|
||||||
|
|
||||||
|
if (tab.title)
|
||||||
|
data += tab.title;
|
||||||
|
|
||||||
|
data += "\n";
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { CollectionItem, GraphicsItem, GraphicsStorage } from "@/models/CollectionModels";
|
||||||
|
import { sendMessage } from "@/utils/messaging";
|
||||||
|
import { collectionStorage } from "./collectionStorage";
|
||||||
|
|
||||||
|
export default async function updateGraphics(
|
||||||
|
collections: CollectionItem[],
|
||||||
|
graphicsCache?: GraphicsStorage
|
||||||
|
): Promise<void>
|
||||||
|
{
|
||||||
|
const localGraphics: GraphicsStorage = await collectionStorage.graphics.getValue();
|
||||||
|
const tempGraphics: GraphicsStorage = graphicsCache || await sendMessage("getGraphicsCache", undefined);
|
||||||
|
|
||||||
|
function getGraphics(url: string): GraphicsItem | null
|
||||||
|
{
|
||||||
|
const preview = tempGraphics[url]?.preview ?? localGraphics[url]?.preview;
|
||||||
|
const icon = tempGraphics[url]?.icon ?? localGraphics[url]?.icon;
|
||||||
|
|
||||||
|
const graphics: GraphicsItem = {};
|
||||||
|
|
||||||
|
if (preview)
|
||||||
|
graphics.preview = preview;
|
||||||
|
if (icon)
|
||||||
|
graphics.icon = icon;
|
||||||
|
|
||||||
|
return preview || icon ? graphics : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newGraphics: GraphicsStorage = {};
|
||||||
|
|
||||||
|
for (const collection of collections)
|
||||||
|
for (const item of collection.items)
|
||||||
|
{
|
||||||
|
if (item.type === "group")
|
||||||
|
for (const tab of item.items)
|
||||||
|
{
|
||||||
|
const graphics = getGraphics(tab.url);
|
||||||
|
|
||||||
|
if (graphics)
|
||||||
|
newGraphics[tab.url] = graphics;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const graphics = getGraphics(item.url);
|
||||||
|
|
||||||
|
if (graphics)
|
||||||
|
newGraphics[item.url] = graphics;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await collectionStorage.graphics.setValue(newGraphics);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import migrateLocalStorage from "../utils/migrateLocalStorage";
|
||||||
|
|
||||||
|
export default function useLocalMigration(): void
|
||||||
|
{
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if (globalThis.localStorage?.getItem("sets"))
|
||||||
|
migrateLocalStorage().then(() => document.location.reload());
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as useLocalMigration } from "./hooks/useLocalMigration";
|
||||||
|
export { default as migrateStorage } from "./utils/migrateStorage";
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export type LegacyCollection =
|
||||||
|
{
|
||||||
|
timestamp: number;
|
||||||
|
tabsCount: number;
|
||||||
|
titles: string[];
|
||||||
|
links: string[];
|
||||||
|
icons?: string[];
|
||||||
|
thumbnails?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LegacyGraphics =
|
||||||
|
{
|
||||||
|
pageCapture?: string;
|
||||||
|
iconUrl?: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { CollectionItem, GraphicsStorage, TabItem } from "@/models/CollectionModels";
|
||||||
|
import { LegacyCollection } from "../models/LegacyModels";
|
||||||
|
|
||||||
|
export default function migrateCollections(legacyCollections: LegacyCollection[]): [CollectionItem[], GraphicsStorage]
|
||||||
|
{
|
||||||
|
const collections: CollectionItem[] = [];
|
||||||
|
const graphics: GraphicsStorage = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < legacyCollections.length; i++)
|
||||||
|
{
|
||||||
|
const legacyCollection: LegacyCollection = legacyCollections[i];
|
||||||
|
const items: TabItem[] = legacyCollection.links.map((url, index) =>
|
||||||
|
{
|
||||||
|
const title: string | undefined = legacyCollection.titles[index];
|
||||||
|
const icon: string | undefined = legacyCollection.icons?.[index];
|
||||||
|
const preview: string | undefined = legacyCollection.thumbnails?.[index];
|
||||||
|
|
||||||
|
if (!graphics[url])
|
||||||
|
graphics[url] = { icon, preview };
|
||||||
|
else
|
||||||
|
graphics[url] = { icon: graphics[url].icon ?? icon, preview: graphics[url].preview ?? preview };
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "tab",
|
||||||
|
url,
|
||||||
|
title
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
collections.push({
|
||||||
|
type: "collection",
|
||||||
|
timestamp: legacyCollection.timestamp,
|
||||||
|
items
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [collections, graphics];
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { getCollections } from "@/features/collectionStorage";
|
||||||
|
import saveCollections from "@/features/collectionStorage/utils/saveCollections";
|
||||||
|
import { LegacyCollection } from "../models/LegacyModels";
|
||||||
|
import migrateCollections from "./migrateCollections";
|
||||||
|
|
||||||
|
export default async function migrateLocalStorage(): Promise<void>
|
||||||
|
{
|
||||||
|
// Retrieve v1 collections
|
||||||
|
const legacyCollections: LegacyCollection[] = JSON.parse(globalThis.localStorage?.getItem("sets") || "[]");
|
||||||
|
|
||||||
|
// Nuke localStorage
|
||||||
|
globalThis.localStorage?.clear();
|
||||||
|
|
||||||
|
// Migrate collections
|
||||||
|
const [resultCollections, resultGraphics] = migrateCollections(legacyCollections);
|
||||||
|
const [collections] = await getCollections();
|
||||||
|
await saveCollections([...collections, ...resultCollections], true, resultGraphics);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user