1
0
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:
2025-05-03 23:59:43 +03:00
parent dbc8c7fd4d
commit 39793a38c3
143 changed files with 14277 additions and 0 deletions
+19
View File
@@ -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
+26
View File
@@ -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"
}
+107
View File
@@ -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
+7
View File
@@ -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
+45
View File
@@ -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
+33
View File
@@ -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`
-->
+14
View File
@@ -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
+144
View File
@@ -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 }}
+86
View File
@@ -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
+31
View File
@@ -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
+57
View File
@@ -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
View File
@@ -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?
+12
View File
@@ -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"
]
}
+11
View File
@@ -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;
+134
View File
@@ -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
+4
View File
@@ -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)
+21
View File
@@ -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.
+8
View File
@@ -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.
+100
View File
@@ -0,0 +1,100 @@
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/xfox111/TabsAsideExtension)](https://github.com/xfox111/TabsAsideExtension/releases/latest)
[![GitHub last commit](https://img.shields.io/github/last-commit/xfox111/TabsAsideExtension?label=Last+update)](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
[![Chrome Web Store](https://img.shields.io/chrome-web-store/users/mgmjbodjgijnebfgohlnjkegdpbdjgin?label=Chrome%20Webstore%20downloads)](https://chrome.google.com/webstore/detail/mgmjbodjgijnebfgohlnjkegdpbdjgin)
[![Mozilla Add-on](https://img.shields.io/amo/users/ms-edge-tabs-aside?label=Firefox%20Webstore%20downloads)](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
[![GitHub issues](https://img.shields.io/github/issues/xfox111/TabsAsideExtension)](https://github.com/xfox111/TabsAsideExtension/issues)
[![CI](https://github.com/XFox111/TabsAsideExtension/actions/workflows/cd_pipeline.yaml/badge.svg)](https://github.com/XFox111/TabsAsideExtension/actions/workflows/cd_pipeline.yaml)
[![GitHub repo size](https://img.shields.io/github/repo-size/xfox111/TabsAsideExtension?label=repo%20size)](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)
---
[![Bluesky](https://img.shields.io/badge/%40xfox111.net-BSky?logo=bluesky&logoColor=%230285FF&label=Bluesky&labelColor=white&color=%230285FF)](https://bsky.app/profile/xfox111.net)
[![GitHub](https://img.shields.io/badge/%40xfox111-GitHub?logo=github&logoColor=%23181717&label=GitHub&labelColor=white&color=%23181717)](https://github.com/xfox111)
[![Buy Me a Coffee](https://img.shields.io/badge/%40xfox111-BMC?logo=buymeacoffee&logoColor=black&label=Buy%20me%20a%20coffee&labelColor=white&color=%23FFDD00)](https://buymeacoffee.com/xfox111)
> ©2025 Eugene Fox. Licensed under [MIT license](https://github.com/XFox111/TabsAsideExtension/blob/main/LICENSE)
+11
View File
@@ -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
+17
View File
@@ -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

+33
View File
@@ -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

+54
View File
@@ -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);
}
+48
View File
@@ -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;
};
+51
View File
@@ -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;
};
+35
View File
@@ -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;
};
+24
View File
@@ -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";
+338
View File
@@ -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);
}
});
+46
View File
@@ -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
}
});
+24
View File
@@ -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>
}
</>
);
}
+45
View File
@@ -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";
+16
View File
@@ -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);
};
+51
View File
@@ -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;
}
+22
View File
@@ -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[];
};
+61
View File
@@ -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;
};
+18
View File
@@ -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>
);
}
+44
View File
@@ -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);
};
}
+128
View File
@@ -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;
}
+117
View File
@@ -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[];
+101
View File
@@ -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"]
}
}
]);
+9
View File
@@ -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());
}, []);
}
+2
View File
@@ -0,0 +1,2 @@
export { default as useLocalMigration } from "./hooks/useLocalMigration";
export { default as migrateStorage } from "./utils/migrateStorage";
+15
View File
@@ -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