From e83ec15749680dbc6d92f0d0222184a02a610a3f Mon Sep 17 00:00:00 2001 From: andyhuang18 <50283262+andyhuang18@users.noreply.github.com> Date: Sun, 22 Oct 2023 20:16:39 +0800 Subject: [PATCH] feat: implement of repo collection [OSPP 2023] (#713) * feat: developed RepoCollection Button through antd component * feat: implement LocalStorage,SetDefault,DeleteCollection,AddCurRepo feature * style: yarn run prettier for index.scss * feat: refactor view * feat: implement storage feature * feat: implement collection modal from popup button * feat: implement collection modal using tabs and repo list beside * feat: implement collection modal from repository dropdown click * feat: solve reopen modal problem * feat: implement collection editor function (quick import need to be done) * feat: add judgment for whether there is initial data * feat: create CollectionEditor component * feat: use GitHub REST API to get repo and its description * feat: implement CollectionEditor add and edit feature * feat: solve formatting and naming issues * feat: refactor data structure * refactor: implement the collection button in the original GitHub style (step 1) * chore: update yarn.lock * fix: update module import * chore: update yarn.lock * fix: duplicate rendering after tab switches * fix: prevent duplicate finally * refactor: give CollectionModal a separate directory * refactor: CollectionList (30%) and AddToCollections (1%) * feat: support basic interactions with CollectionButton * chore: disable charts-design * chore: allow overflow when there are many exsiting collections * chore: add comments * chore: add manage button in AddToCollections * refactor: a possible final directory * chore: support to open modal from CollectionList * fix: fix typo * feat: add confirm check before collection deletion * feat: set default key for Table and add Radio for quick import (User/Organization) * refactor: move custom type definition to context.ts * refactor: use constate to better manage locally global state * feat: store and useStore for repo-collection * chore: update repo name after turbo:restore * feat: replace some part with real data * feat: AddToCollections(100%) * fix: a simple lock mechanism to prevent concurrent updates in store.ts * feat: implement CollectionDisplayModal and rename filename * refactor: rename view.tsx * feat: add divider for footer in AddToCollections * refactor: remove redundant initialization * refactor: rollback and delete displayModal * feat: implement of CollectionManageModal * feat: implement of CollectionEditor confirm * feat: use await/async function * chore: fix typo and add validator for duplicate collection name * style: maximize the collection modal size --------- Co-authored-by: Lam Tang --- package.json | 3 +- .../CollectionButton/AddToCollections.tsx | 189 +++++++++++ .../CollectionButton/CollectionList.tsx | 127 ++++++++ .../CollectionButton/index.tsx | 41 +++ .../CollectionModal/CollectionEditor.tsx | 298 ++++++++++++++++++ .../repo-collection/CollectionModal/index.tsx | 255 +++++++++++++++ .../features/repo-collection/context/index.ts | 48 +++ .../features/repo-collection/context/store.ts | 202 ++++++++++++ .../repo-collection/context/useStore.ts | 56 ++++ .../features/repo-collection/index.tsx | 39 +++ .../features/repo-collection/view.tsx | 20 ++ src/pages/ContentScripts/index.ts | 2 +- src/pages/Popup/Popup.tsx | 4 +- yarn.lock | 33 +- 14 files changed, 1299 insertions(+), 18 deletions(-) create mode 100644 src/pages/ContentScripts/features/repo-collection/CollectionButton/AddToCollections.tsx create mode 100644 src/pages/ContentScripts/features/repo-collection/CollectionButton/CollectionList.tsx create mode 100644 src/pages/ContentScripts/features/repo-collection/CollectionButton/index.tsx create mode 100644 src/pages/ContentScripts/features/repo-collection/CollectionModal/CollectionEditor.tsx create mode 100644 src/pages/ContentScripts/features/repo-collection/CollectionModal/index.tsx create mode 100644 src/pages/ContentScripts/features/repo-collection/context/index.ts create mode 100644 src/pages/ContentScripts/features/repo-collection/context/store.ts create mode 100644 src/pages/ContentScripts/features/repo-collection/context/useStore.ts create mode 100644 src/pages/ContentScripts/features/repo-collection/index.tsx create mode 100644 src/pages/ContentScripts/features/repo-collection/view.tsx diff --git a/package.json b/package.json index ebd93adc..133169c0 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "description": "Hypertrons Chromium Extension", "license": "Apache", "engines": { - "node": ">=16.14" + "node": ">=18" }, "scripts": { "build": "cross-env NODE_ENV='production' BABEL_ENV='production' node utils/build.js", @@ -28,6 +28,7 @@ "antd": "^5.9.1", "buffer": "^6.0.3", "colorthief": "^2.4.0", + "constate": "^3.3.2", "delay": "^5.0.0", "dom-loaded": "^3.0.0", "echarts": "^5.3.0", diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionButton/AddToCollections.tsx b/src/pages/ContentScripts/features/repo-collection/CollectionButton/AddToCollections.tsx new file mode 100644 index 00000000..fabacb6e --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/CollectionButton/AddToCollections.tsx @@ -0,0 +1,189 @@ +import { useRepoCollectionContext } from '../context'; +import { Collection } from '../context/store'; + +import React, { useEffect, useState } from 'react'; + +const CheckListItem = ( + collection: Collection, + onChange: (collectionId: Collection['id'], checked: boolean) => void, + checked: boolean +) => { + const handleChange = () => { + onChange(collection.id, checked); + }; + + return ( +
+ +
+ ); +}; + +/** + * The modal for quickly adding the current repository to existing collections (also for removing) + */ +export const AddToCollections = () => { + const { + currentRepositoryId, + currentRepositoryCollections, + allCollections, + updaters, + hideAddToCollections, + setHideAddToCollections, + setHideCollectionList, + setShowManageModal, + } = useRepoCollectionContext(); + + const [checkedCollectionIds, setCheckedCollectionIds] = useState< + Collection['id'][] + >([]); + + const resetCheckboxes = () => { + setCheckedCollectionIds(currentRepositoryCollections.map((c) => c.id)); + }; + + // reset checkboxes when currentRepositoryCollections changes + useEffect(() => { + resetCheckboxes(); + }, [currentRepositoryCollections]); + + const handleCheckChange = ( + collectionId: Collection['id'], + checked: boolean + ) => { + if (checked) { + setCheckedCollectionIds( + checkedCollectionIds.filter((id) => id !== collectionId) + ); + } else { + setCheckedCollectionIds([...checkedCollectionIds, collectionId]); + } + }; + + const goToCollectionList = () => { + setHideAddToCollections(true); + setHideCollectionList(false); + }; + + const apply = () => { + // add/remove relations + const toAdd = checkedCollectionIds.filter( + (id) => !currentRepositoryCollections.some((c) => c.id === id) + ); + const toRemove = currentRepositoryCollections.filter( + (c) => !checkedCollectionIds.includes(c.id) + ); + toAdd && + updaters.addRelations( + toAdd.map((id) => ({ + collectionId: id, + repositoryId: currentRepositoryId, + })) + ); + toRemove && + updaters.removeRelations( + toRemove.map((c) => ({ + collectionId: c.id, + repositoryId: currentRepositoryId, + })) + ); + + goToCollectionList(); + }; + + const cancel = () => { + resetCheckboxes(); + + goToCollectionList(); + }; + + const manage = () => { + // open modal to manage collections + setShowManageModal(true); + }; + + // if the ids of currentRepositoryCollections are the same as the ids of selectedCollectionIds, then the "Apply" button should be disabled + let isApplyDisabled: boolean; + if (currentRepositoryCollections.length !== checkedCollectionIds.length) { + isApplyDisabled = false; + } else { + isApplyDisabled = currentRepositoryCollections.every((c) => + checkedCollectionIds.includes(c.id) + ); + } + + return ( + + ); +}; diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionButton/CollectionList.tsx b/src/pages/ContentScripts/features/repo-collection/CollectionButton/CollectionList.tsx new file mode 100644 index 00000000..73ece968 --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/CollectionButton/CollectionList.tsx @@ -0,0 +1,127 @@ +import { useRepoCollectionContext } from '../context'; +import { Collection } from '../context/store'; + +import React from 'react'; + +const ListItem = ( + collection: Collection, + onClick: (collectionId: Collection['id']) => void +) => { + const handleClick = () => { + onClick(collection.id); + }; + + return ( +
+ + {collection.name} + +
+ ); +}; + +/** + * The modal that shows the collections that the repo belongs to + */ +export const CollectionList = () => { + const { + currentRepositoryCollections, + hideCollectionList, + setHideAddToCollections, + setHideCollectionList, + setSelectedCollection, + setShowManageModal, + } = useRepoCollectionContext(); + + const handleCollectionClick = (collectionId: Collection['id']) => { + setSelectedCollection(collectionId); + setShowManageModal(true); + }; + + const goToAddToCollections = () => { + setHideAddToCollections(false); + setHideCollectionList(true); + }; + + return ( + + ); +}; diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionButton/index.tsx b/src/pages/ContentScripts/features/repo-collection/CollectionButton/index.tsx new file mode 100644 index 00000000..74b65cb2 --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/CollectionButton/index.tsx @@ -0,0 +1,41 @@ +import { CollectionList } from './CollectionList'; +import { AddToCollections } from './AddToCollections'; +import { useRepoCollectionContext } from '../context'; + +import React from 'react'; +import { FundProjectionScreenOutlined } from '@ant-design/icons'; + +/** + * The "Collections" button, which is in the left of the "Edit Pins" button + */ +export const CollectionButton = () => { + const { currentRepositoryCollections } = useRepoCollectionContext(); + + return ( +
+
+ + + + {' Collections '} + + + {currentRepositoryCollections.length} + + + + + +
+
+ ); +}; diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionModal/CollectionEditor.tsx b/src/pages/ContentScripts/features/repo-collection/CollectionModal/CollectionEditor.tsx new file mode 100644 index 00000000..734207cf --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/CollectionModal/CollectionEditor.tsx @@ -0,0 +1,298 @@ +import { useRepoCollectionContext } from '../context'; + +import React, { useEffect, useState } from 'react'; +import { + Modal, + Col, + Row, + Button, + Form, + Input, + Table, + Divider, + Radio, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; + +interface Values { + name: string; + quickImport: string; +} + +interface DataType { + key: React.Key; + name: string; + description: string; +} + +interface RepositoryInfo { + name: string; + description: string; +} + +interface CollectionEditorProps { + open: boolean; + onCreate: (values: Values, newRepoData: string[] | undefined) => void; + onCancel: () => void; + isEdit: boolean | undefined; + collectionName: string; + collectionData: string[]; +} + +interface DataSourceType { + key: string; + name: string; + description: string; +} + +const accessTokens = ['token']; + +let currentTokenIndex = 0; + +async function getUserOrOrgRepos( + username: string, + isOrg: boolean +): Promise { + try { + const currentAccessToken = accessTokens[currentTokenIndex]; + + const apiUrl = isOrg + ? `https://api.github.com/orgs/${username}/repos` + : `https://api.github.com/users/${username}/repos`; + + const response = await fetch(apiUrl, { + headers: { + Authorization: `Bearer ${currentAccessToken}`, + }, + }); + + if (!response.ok) { + if (response.status === 401) { + currentTokenIndex = (currentTokenIndex + 1) % accessTokens.length; // switch to next token + return getUserOrOrgRepos(username, isOrg); + } else { + throw new Error( + `GitHub API request failed with status: ${response.status}` + ); + } + } + + const reposData = await response.json(); + + const repositories: RepositoryInfo[] = reposData.map((repo: any) => ({ + name: repo.name, + description: repo.description || '', + })); + + return repositories; + } catch (error) { + console.error('Error fetching repositories:', error); + throw error; + } +} + +// TODO 需要找到一个合适的方法解决Token的问题... + +const columns: ColumnsType = [ + { + title: 'name', + dataIndex: 'name', + }, + { + title: 'description', + dataIndex: 'description', + }, +]; + +const CollectionEditor: React.FC = ({ + open, + onCreate, + onCancel, + isEdit, + collectionName, + collectionData, +}) => { + const { allCollections } = useRepoCollectionContext(); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [form] = Form.useForm(); + const [dataSource, setDataSource] = useState(); + const [newRepoData, setNewRepoData] = useState(collectionData); + const [isOrg, setIsOrg] = useState(false); + + async function fetchRepositoryDescription(repositoryName: string) { + const apiUrl = `https://api.github.com/repos/${repositoryName}`; + + const response = await fetch(apiUrl); + if (!response.ok) { + throw new Error( + `GitHub API request failed for ${repositoryName} with status: ${response.status}` + ); + } + const repoData = await response.json(); + return { + key: collectionData.indexOf(repositoryName).toString(), + name: repositoryName, + description: repoData.description || '', + }; + } + + useEffect(() => { + Promise.all( + collectionData.map((repositoryName) => + fetchRepositoryDescription(repositoryName) + ) + ) + .then((repositoryDescriptions) => { + setDataSource(repositoryDescriptions); + }) + .catch((error) => { + console.error('Error fetching repository descriptions:', error); + }); + }, []); + + const initialValues = { + collectionName: isEdit ? collectionName : '', + }; + const modalTitle = isEdit ? 'Collection Editor' : 'Create a new collection'; + + const onSelectChange = ( + newSelectedRowKeys: React.Key[], + selectedRows: DataType[] + ) => { + setNewRepoData(selectedRows.map((item) => item.name)); + setSelectedRowKeys(newSelectedRowKeys); + }; + + const defaultSelectedRowKeys: React.Key[] = Array.from( + { length: collectionData.length }, + (_, index) => index.toString() + ); + const rowSelection = { + defaultSelectedRowKeys, + onChange: onSelectChange, + }; + + const handleInquireClick = () => { + const inputValue = form.getFieldValue('Quick import'); + async function fetchRepositories() { + try { + const result = await getUserOrOrgRepos(inputValue, isOrg); + let nextKey: number; + if (dataSource) { + nextKey = dataSource.length + 1; + } else { + nextKey = 1; + } + const addKeyValue = [ + ...result.map((repo) => ({ + key: (nextKey++).toString(), + name: repo.name, + description: repo.description, + })), + ]; + if (dataSource) { + setDataSource([...dataSource, ...addKeyValue]); + } else { + setDataSource(addKeyValue); + } + } catch (error) { + // 处理错误 + console.error('Error:', error); + } + } + + fetchRepositories(); + }; + + function handleImportClick() { + console.log('newRepoData', newRepoData); + } + + return ( + { + form + .validateFields() + .then((values) => { + form.resetFields(); + onCreate(values, newRepoData); + }) + .catch((info) => { + console.log('Validate Failed:', info); + }); + }} + > +
+ { + const existingNames = allCollections; + if (existingNames.some((item) => item.name === value)) { + return Promise.reject('Collection name already exists.'); + } + return Promise.resolve(); + }, + }, + ]} + > + + + + + + +
+ { + const selectedValue = e.target.value; + setIsOrg(selectedValue === 'Organization'); + }} + > + User + Organization + + + +
+
+ + + + + + + + ); +}; + +export default CollectionEditor; diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionModal/index.tsx b/src/pages/ContentScripts/features/repo-collection/CollectionModal/index.tsx new file mode 100644 index 00000000..b5281f80 --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/CollectionModal/index.tsx @@ -0,0 +1,255 @@ +import { useRepoCollectionContext } from '../context'; +import CollectionEditor from './CollectionEditor'; + +import React, { useState, useEffect } from 'react'; +import { Modal, Tabs, List, Col, Row, Button } from 'antd'; + +type TargetKey = React.MouseEvent | React.KeyboardEvent | string; + +type CollectionTabType = { + label: string; + children: string; + key: string; +}; + +export const CollectionManageModal = () => { + const { + showManageModal, + setShowManageModal, + selectedCollection, + setSelectedCollection, + updaters, + allCollections, + allRelations, + } = useRepoCollectionContext(); + + const [activeKey, setActiveKey] = useState(); + const [items, setItems] = useState([]); + const [listData, setListData] = useState( + allRelations + .filter((relation) => relation.collectionId === allCollections[0].id) + .map((relation) => relation.repositoryId) + ); + const [isClick, setIsClick] = useState(false); + const [isEdit, setIsEdit] = useState(); + + const editTab = ( +
+ + +
+ ); + + useEffect(() => { + const initialItems = allCollections.map((collection, index) => ({ + label: collection.name, + children: `Content of ${collection.name}`, + key: collection.id, + })); + const initialListData = allRelations + .filter((relation) => + relation.collectionId === selectedCollection + ? selectedCollection + : allCollections[0].name + ) + .map((relation) => relation.repositoryId); + setActiveKey(selectedCollection); + setItems(initialItems); + setListData(initialListData); + }, [showManageModal]); + + useEffect(() => {}, []); + + const onCreate = async (values: any, newRepoData: string[] | undefined) => { + if (isEdit) { + const updatedItems = items.map((item) => { + if (item.key === activeKey?.toString()) { + return { ...item, label: values.collectionName }; + } + return item; + }); + setItems(updatedItems); + } else { + const newPanes = [...items]; + newPanes.push({ + label: values.collectionName, + children: `Content of ${values.collectionName}`, + key: values.collectionName, + }); + setItems(newPanes); + setActiveKey(values.collectionName); + } + + try { + /* + * remove collection and its relations + */ + if (selectedCollection) { + await updaters.removeCollection(selectedCollection); + const relationsToRemove = allRelations.filter( + (relation) => relation.collectionId === selectedCollection + ); + await updaters.removeRelations(relationsToRemove); + } + + /* + * add newCollection and its relations + */ + + await updaters.addCollection({ + id: values.collectionName, + name: values.collectionName, + }); + if (newRepoData) { + const relationsToAdd = newRepoData.map((repo) => ({ + collectionId: values.collectionName, + repositoryId: repo, + })); + await updaters.addRelations(relationsToAdd); + } + } catch (error) { + console.error('Error:', error); + } + console.log('Received values of form: ', values); + + setListData(newRepoData); + setSelectedCollection(values.collectionName); + setIsClick(false); + setIsEdit(undefined); + }; + + const onChange = (newActiveKey: string) => { + setActiveKey(newActiveKey); + setSelectedCollection(newActiveKey); + }; + + const remove = (targetKey: TargetKey) => { + Modal.confirm({ + title: 'Confirm Deletion', + content: 'Are you sure you want to delete this collection?', + okText: 'Confirm', + async onOk() { + // 用户点击确认按钮时执行的操作 + let newActiveKey = activeKey; + let lastIndex = -1; + items.forEach((item, i) => { + if (item.key === targetKey) { + lastIndex = i - 1; + } + }); + const newPanes = items.filter((item) => item.key !== targetKey); + if (newPanes.length && newActiveKey === targetKey) { + if (lastIndex >= 0) { + newActiveKey = newPanes[lastIndex].key; + } else { + newActiveKey = newPanes[0].key; + } + } + setItems(newPanes); + setActiveKey(newActiveKey); + await updaters.removeCollection(targetKey.toString()); + await updaters.removeRelations( + allRelations.filter( + (relation) => relation.collectionId === targetKey.toString() + ) + ); + setSelectedCollection(newActiveKey); + }, + onCancel() {}, + }); + }; + + const onEdit = ( + targetKey: React.MouseEvent | React.KeyboardEvent | string, + action: 'add' | 'remove' + ) => { + if (action === 'remove') remove(targetKey); + }; + + return ( +
+ { + setShowManageModal(false); + setSelectedCollection(undefined); + }} + footer={null} + width={'100%'} + style={{ + top: '0px', + bottom: '0px', + height: '100vh', + maxWidth: 'unset', + }} + bodyStyle={{ height: 'calc(100vh - 40px)' }} // 40px is the sum of top and bottom padding + > + +
+
+ + {selectedCollection + ? selectedCollection + : 'Select tab first'} +
+ } + bordered + dataSource={allRelations + .filter( + (relation) => relation.collectionId === selectedCollection + ) + .map((relation) => relation.repositoryId)} + renderItem={(item) => {item}} + /> + + + + + + + + {isClick && ( + { + setIsClick(false); + setIsEdit(undefined); + }} + isEdit={isEdit} + collectionName={selectedCollection ? selectedCollection : ''} + collectionData={allRelations + .filter((relation) => relation.collectionId === selectedCollection) + .map((relation) => relation.repositoryId)} + /> + )} + + ); +}; diff --git a/src/pages/ContentScripts/features/repo-collection/context/index.ts b/src/pages/ContentScripts/features/repo-collection/context/index.ts new file mode 100644 index 00000000..6953774d --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/context/index.ts @@ -0,0 +1,48 @@ +import { Repository } from './store'; +import { useStore } from './useStore'; + +import { useState } from 'react'; +import constate from 'constate'; + +const useRepoCollection = ({ + currentRepositoryId, +}: { + currentRepositoryId: Repository['id']; +}) => { + const { allCollections, allRelations, updaters } = useStore(); + // get all related collections for the current repository + const currentRepositoryRelations = allRelations.filter( + (r) => r.repositoryId === currentRepositoryId + ); + const currentRepositoryCollections = allCollections.filter((c) => + currentRepositoryRelations.some((r) => r.collectionId === c.id) + ); + + const [hideCollectionList, setHideCollectionList] = useState(false); + const [hideAddToCollections, setHideAddToCollections] = useState(true); + + const [showManageModal, setShowManageModal] = useState(false); + const [selectedCollection, setSelectedCollection] = useState(); + + return { + currentRepositoryId, + currentRepositoryCollections, + allCollections, + allRelations, + updaters, + + hideCollectionList, + setHideCollectionList, + hideAddToCollections, + setHideAddToCollections, + + showManageModal, + setShowManageModal, + + selectedCollection, + setSelectedCollection, + }; +}; + +export const [RepoCollectionProvider, useRepoCollectionContext] = + constate(useRepoCollection); diff --git a/src/pages/ContentScripts/features/repo-collection/context/store.ts b/src/pages/ContentScripts/features/repo-collection/context/store.ts new file mode 100644 index 00000000..d7b25530 --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/context/store.ts @@ -0,0 +1,202 @@ +export interface Repository { + id: string; + /** e.g. microsoft/vscode */ + fullName: string; +} + +export interface Collection { + id: string; + name: string; +} + +export interface Relation { + repositoryId: Repository['id']; + collectionId: Collection['id']; +} + +const RELATIONS_STORE_KEY = + 'hypercrx:repo-collection:repository-collection-relations'; +const COLLECTIONS_STORE_KEY = 'hypercrx:repo-collection:collections'; + +// TODO: delete other collections except for X-lab before the PR is merged +const defaultCollections: Collection[] = [ + { + id: 'X-lab', + name: 'X-lab', + }, + { + id: 'Hypertrons', + name: 'Hypertrons', + }, + { + id: 'Mulan', + name: 'Mulan', + }, +]; +const defaultRelations: Relation[] = [ + { + repositoryId: 'X-lab2017/open-digger', + collectionId: 'X-lab', + }, + { + repositoryId: 'hypertrons/hypertrons-crx', + collectionId: 'X-lab', + }, + { + repositoryId: 'hypertrons/hypertrons-crx', + collectionId: 'Hypertrons', + }, +]; + +/** + * Store for repo collection + */ +class RepoCollectionStore { + private static instance: RepoCollectionStore; + // a simple lock mechanism to prevent concurrent updates + private isUpdatingRelations: boolean = false; + private isUpdatingCollection: boolean = false; + + public static getInstance(): RepoCollectionStore { + if (!RepoCollectionStore.instance) { + RepoCollectionStore.instance = new RepoCollectionStore(); + } + return RepoCollectionStore.instance; + } + + public async addCollection(collection: Collection): Promise { + if (this.isUpdatingCollection) { + // Another update is in progress, wait for it to finish + await this.waitForUpdateToFinish(); + } + + this.isUpdatingCollection = true; + + try { + const collections = await this.getAllCollections(); + collections.push(collection); + await chrome.storage.sync.set({ + [COLLECTIONS_STORE_KEY]: collections, + }); + } finally { + this.isUpdatingCollection = false; + } + } + + public async removeCollection(collectionId: Collection['id']): Promise { + if (this.isUpdatingCollection) { + // Another update is in progress, wait for it to finish + await this.waitForUpdateToFinish(); + } + + this.isUpdatingCollection = true; + + try { + const collections = await this.getAllCollections(); + const index = collections.findIndex((c) => c.id === collectionId); + if (index === -1) { + return; + } + // Remove its relations first + const relations = await this.getAllRelations(); + relations.forEach((r) => { + if (r.collectionId === collectionId) { + this.removeRelations([r]); + } + }); + // Then remove the collection + collections.splice(index, 1); + await chrome.storage.sync.set({ + [COLLECTIONS_STORE_KEY]: collections, + }); + } finally { + this.isUpdatingCollection = false; + } + } + + public async getAllCollections(): Promise { + const collections = await chrome.storage.sync.get({ + [COLLECTIONS_STORE_KEY]: defaultCollections, + }); + return collections[COLLECTIONS_STORE_KEY]; + } + + public async addRelations(relations: Relation[]): Promise { + if (this.isUpdatingRelations) { + // Another update is in progress, wait for it to finish + await this.waitForUpdateToFinish(); + } + + this.isUpdatingRelations = true; + + try { + const allRelations = await this.getAllRelations(); + // Remove duplicate relations + relations = relations.filter((r) => { + return ( + allRelations.findIndex( + (rr) => + rr.repositoryId === r.repositoryId && + rr.collectionId === r.collectionId + ) === -1 + ); + }); + allRelations.push(...relations); + await chrome.storage.sync.set({ + [RELATIONS_STORE_KEY]: allRelations, + }); + } finally { + this.isUpdatingRelations = false; + } + } + + public async removeRelations(relations: Relation[]): Promise { + if (this.isUpdatingRelations) { + // Another update is in progress, wait for it to finish + await this.waitForUpdateToFinish(); + } + + this.isUpdatingRelations = true; + + try { + const allRelations = await this.getAllRelations(); + relations.forEach((r) => { + const index = allRelations.findIndex( + (rr) => + rr.repositoryId === r.repositoryId && + rr.collectionId === r.collectionId + ); + if (index !== -1) { + allRelations.splice(index, 1); + } + }); + await chrome.storage.sync.set({ + [RELATIONS_STORE_KEY]: allRelations, + }); + } finally { + this.isUpdatingRelations = false; + } + } + + public async getAllRelations(): Promise { + const relations = await chrome.storage.sync.get({ + [RELATIONS_STORE_KEY]: defaultRelations, + }); + return relations[RELATIONS_STORE_KEY]; + } + + private async waitForUpdateToFinish(): Promise { + return new Promise((resolve) => { + const check = () => { + if (!this.isUpdatingRelations) { + resolve(); + } else { + setTimeout(check, 10); // Check again after a short delay + } + }; + check(); + }); + } +} + +export const repoCollectionStore = RepoCollectionStore.getInstance(); diff --git a/src/pages/ContentScripts/features/repo-collection/context/useStore.ts b/src/pages/ContentScripts/features/repo-collection/context/useStore.ts new file mode 100644 index 00000000..ac278acc --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/context/useStore.ts @@ -0,0 +1,56 @@ +import { repoCollectionStore, Collection, Relation } from './store'; + +import { useState, useEffect } from 'react'; + +export const useStore = () => { + const [allCollections, setAllCollections] = useState([]); + const [allRelations, setAllRelations] = useState([]); + + const fetchAllCollections = async () => { + const collections = await repoCollectionStore.getAllCollections(); + setAllCollections(collections); + }; + + const fetchAllRelations = async () => { + const relations = await repoCollectionStore.getAllRelations(); + setAllRelations(relations); + }; + + const addRelations = async (relations: Relation[]) => { + await repoCollectionStore.addRelations(relations); + fetchAllRelations(); + }; + + const removeRelations = async (relations: Relation[]) => { + await repoCollectionStore.removeRelations(relations); + fetchAllRelations(); + }; + + const addCollection = async (collection: Collection) => { + await repoCollectionStore.addCollection(collection); + fetchAllCollections(); + }; + + const removeCollection = async (collectionId: Collection['id']) => { + await repoCollectionStore.removeCollection(collectionId); + fetchAllCollections(); + }; + + const updaters = { + addRelations, + removeRelations, + addCollection, + removeCollection, + }; + + useEffect(() => { + fetchAllCollections(); + fetchAllRelations(); + }, []); + + return { + allCollections, + allRelations, + updaters, + }; +}; diff --git a/src/pages/ContentScripts/features/repo-collection/index.tsx b/src/pages/ContentScripts/features/repo-collection/index.tsx new file mode 100644 index 00000000..b072699a --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/index.tsx @@ -0,0 +1,39 @@ +import features from '../../../../feature-manager'; +import { isPublicRepo, getRepoName } from '../../../../helpers/get-repo-info'; +import View from './view'; + +import React from 'react'; +import { render, Container } from 'react-dom'; +import $ from 'jquery'; +import elementReady from 'element-ready'; + +const featureId = features.getFeatureID(import.meta.url); +let repoName: string; + +const renderTo = (container: Container) => { + render(, container); +}; + +const init = async (): Promise => { + repoName = getRepoName(); + + const container = document.createElement('li'); + container.id = featureId; + renderTo(container); + await elementReady('#repository-details-container'); + $('#repository-details-container>ul').prepend(container); +}; + +const restore = async () => { + if (repoName !== getRepoName()) { + repoName = getRepoName(); + } + renderTo($(`#${featureId}`)[0]); +}; + +features.add(featureId, { + include: [isPublicRepo], + awaitDomReady: true, + init, + restore, +}); diff --git a/src/pages/ContentScripts/features/repo-collection/view.tsx b/src/pages/ContentScripts/features/repo-collection/view.tsx new file mode 100644 index 00000000..6fe28a00 --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/view.tsx @@ -0,0 +1,20 @@ +import { CollectionButton } from './CollectionButton'; +import { CollectionManageModal } from './CollectionModal'; +import { RepoCollectionProvider } from './context'; + +import React from 'react'; + +interface Props { + repoName: string; +} + +const View = ({ repoName }: Props) => { + return ( + + + + + ); +}; + +export default View; diff --git a/src/pages/ContentScripts/index.ts b/src/pages/ContentScripts/index.ts index 20fe4af2..9288402a 100644 --- a/src/pages/ContentScripts/index.ts +++ b/src/pages/ContentScripts/index.ts @@ -13,4 +13,4 @@ import './features/repo-networks'; import './features/developer-networks'; import './features/oss-gpt'; import './features/repo-activity-racing-bar'; -import './features/charts-design'; +import './features/repo-collection'; diff --git a/src/pages/Popup/Popup.tsx b/src/pages/Popup/Popup.tsx index fa045d9c..d30998ff 100644 --- a/src/pages/Popup/Popup.tsx +++ b/src/pages/Popup/Popup.tsx @@ -14,8 +14,8 @@ export default function Popup() { }); }; return ( -
- +
+