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/api/repo.ts b/src/api/repo.ts index 63858546..7fce3104 100644 --- a/src/api/repo.ts +++ b/src/api/repo.ts @@ -19,6 +19,7 @@ const metricNameMap = new Map([ ['developer_network', 'developer_network'], ['repo_network', 'repo_network'], ['activity_details', 'activity_details'], + ['issue_response_time', 'issue_response_time'], ]); export const getActivity = async (repo: string) => { @@ -88,3 +89,7 @@ export const getRepoNetwork = async (repo: string) => { export const getActivityDetails = async (repo: string) => { return getMetricByName(repo, metricNameMap, 'activity_details'); }; + +export const getIssueResponseTime = async (repo: string) => { + return getMetricByName(repo, metricNameMap, 'issue_response_time'); +}; diff --git a/src/helpers/get-newest-month.ts b/src/helpers/get-newest-month.ts new file mode 100644 index 00000000..4f9b2160 --- /dev/null +++ b/src/helpers/get-newest-month.ts @@ -0,0 +1,14 @@ +const getNewestMonth = () => { + const now = new Date(); + if (now.getDate() === 1) { + // data for last month is not ready in the first day of the month (#595) + now.setDate(0); // a way to let month - 1 + } + now.setDate(0); // see issue #632 + + return ( + now.getFullYear() + '-' + (now.getMonth() + 1).toString().padStart(2, '0') + ); +}; + +export default getNewestMonth; 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..3aa1cf71 --- /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, + setShowCollectionModal, + } = 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 + setShowCollectionModal(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..ca11cb77 --- /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, + setShowCollectionModal, + } = useRepoCollectionContext(); + + const handleCollectionClick = (collectionId: Collection['id']) => { + setSelectedCollection(collectionId); + setShowCollectionModal(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/CollectionContent/ChartCard.css b/src/pages/ContentScripts/features/repo-collection/CollectionContent/ChartCard.css new file mode 100644 index 00000000..3ed0d540 --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/CollectionContent/ChartCard.css @@ -0,0 +1,6 @@ +/* ChartCard.css */ + +.custom-card { + background-color: #fafafa; + /* 其他样式属性 */ +} diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionContent/ChartCard.tsx b/src/pages/ContentScripts/features/repo-collection/CollectionContent/ChartCard.tsx new file mode 100644 index 00000000..13856c10 --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/CollectionContent/ChartCard.tsx @@ -0,0 +1,23 @@ +import React, { ReactNode } from 'react'; +import { Card } from 'antd'; +import './ChartCard.css'; // 导入自定义样式 + +interface ChartCardProps { + title: ReactNode; + children: React.ReactNode; +} + +function ChartCard({ title, children }: ChartCardProps) { + return ( + + {children} + + ); +} + +export default ChartCard; diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionContent/CollectionDashboard.tsx b/src/pages/ContentScripts/features/repo-collection/CollectionContent/CollectionDashboard.tsx new file mode 100644 index 00000000..353e590a --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/CollectionContent/CollectionDashboard.tsx @@ -0,0 +1,179 @@ +import LineChart from '../charts/LineChart'; +import BarChart from '../charts/BarChart'; +import SankeyChart from '../charts/SankeyChart'; +import PieChart from '../charts/PieChart'; +import StackedBarChart from '../charts/StackedBarChart'; +import CodeStackedBarChart from '../charts/CodeStackedBarChart'; +import BoxplotChart from '../charts/BoxplotChart'; +import ChartCard from './ChartCard'; +import NumericPanel from '../charts/NumericPanel'; + +import React from 'react'; +import { Row, Col } from 'antd'; + +import './index.scss'; + +interface CollectionDashboardProps { + repoNames: string[]; + currentRepo?: string; +} + +const CollectionDashboard: React.FC = ({ + repoNames, + currentRepo, +}) => { + return ( + <> + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + ); +}; + +export default CollectionDashboard; diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionContent/index.scss b/src/pages/ContentScripts/features/repo-collection/CollectionContent/index.scss new file mode 100644 index 00000000..7d083f0a --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/CollectionContent/index.scss @@ -0,0 +1,12 @@ +.ant-row { + margin-right: 0 !important; + margin-left: 0 !important; +} + +.ant-col:first-child { + padding-left: 0 !important; +} + +.ant-col:last-child { + padding-right: 0 !important; +} diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionContent/index.tsx b/src/pages/ContentScripts/features/repo-collection/CollectionContent/index.tsx new file mode 100644 index 00000000..81582afe --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/CollectionContent/index.tsx @@ -0,0 +1,62 @@ +// Index.tsx +import React, { useState, useEffect } from 'react'; +import { Layout, Menu, theme } from 'antd'; + +import CollectionDashboard from './CollectionDashboard'; + +const { Content, Sider } = Layout; +interface Index { + repoNames: string[]; + + currentRepo?: string; +} + +const LIGHT_THEME = { + BG_COLOR: '#ffffff', +}; + +const CollectionContent: React.FC = ({ repoNames, currentRepo }) => { + const menuItems = repoNames.map((repo, index) => ({ + key: index, + label: repo, + })); + const { + token: { colorBgContainer }, + } = theme.useToken(); + + // 添加一个状态来跟踪选中的仓库名 + const [selectedRepo, setSelectedRepo] = useState( + undefined + ); + useEffect(() => { + setSelectedRepo(currentRepo); + }, [currentRepo]); + const handleMenuClick = (key: string) => { + setSelectedRepo(key); + }; + + return ( + + + handleMenuClick(key)} //点击切换选中的repo + /> + + + + + + ); +}; + +export default CollectionContent; 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..a387ac58 --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/CollectionModal/CollectionEditor.tsx @@ -0,0 +1,318 @@ +import { useRepoCollectionContext } from '../context'; + +import React, { useEffect, useState } from 'react'; +import { + Button, + Col, + Divider, + Form, + Input, + Modal, + Radio, + Row, + Table, +} 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[]) => 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, + addType: string +): Promise { + try { + const currentAccessToken = accessTokens[currentTokenIndex]; + let apiUrl = ''; + if (addType === 'User') { + apiUrl = `https://api.github.com/users/${username}/repos`; + } else if (addType === 'Organization') { + apiUrl = `https://api.github.com/orgs/${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, addType); + } else { + throw new Error( + `GitHub API request failed with status: ${response.status}` + ); + } + } + + const reposData = await response.json(); + + return reposData.map((repo: any) => ({ + name: repo.name, + description: repo.description || '', + })); + } 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 [addType, setAddType] = useState('FullName'); + + 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(() => { + if (isEdit) { + 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 handleSearchClick = () => { + const inputValue = form.getFieldValue('Quick import'); + if (addType === 'FullName') { + fetchRepositoryDescription(inputValue) + .then((repoDescription) => { + const key = dataSource ? dataSource.length + 1 : 1; + repoDescription.key = key.toString(); + console.log('repoDescription', repoDescription); + if (dataSource) { + setDataSource([...dataSource, repoDescription]); + } else { + console.log('repoDescription', repoDescription); + setDataSource([repoDescription]); + } + }) + .catch((error) => { + console.error('Error fetching repository description:', error); + }); + } else { + fetchRepositories(); + } + + async function fetchRepositories() { + try { + const result = await getUserOrOrgRepos(inputValue, addType); + 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); + } + } + }; + + function handleImportClick() { + console.log('newRepoData', newRepoData); + } + + return ( + { + form + .validateFields() + .then((values) => { + form.resetFields(); + onCreate(values, newRepoData); + }) + .catch((info) => { + console.log('Validate Failed:', info); + }); + }} + > +
+ { + const editedCollectionName = isEdit ? collectionName : null; + if (value === editedCollectionName) { + return Promise.resolve(); + } + if (allCollections.some((item) => item.name === value)) { + return Promise.reject('Collection name already exists.'); + } + return Promise.resolve(); + }, + }, + ]} + > + + + + + + +
+ { + const selectedValue = e.target.value; + setAddType(selectedValue); + }} + > + FullName + User + Organization + + + +
+
+ + + + + + + + ); +}; + +export default CollectionEditor; diff --git a/src/pages/ContentScripts/features/repo-collection/CollectionModal/index.scss b/src/pages/ContentScripts/features/repo-collection/CollectionModal/index.scss new file mode 100644 index 00000000..17d9dca8 --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/CollectionModal/index.scss @@ -0,0 +1,3 @@ +.ant-tabs-content-holder { + overflow: auto; +} 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..a1ca36f2 --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/CollectionModal/index.tsx @@ -0,0 +1,216 @@ +import { useRepoCollectionContext } from '../context'; +import CollectionEditor from './CollectionEditor'; +import CollectionContent from '../CollectionContent'; + +import React, { useState } from 'react'; +import { Modal, Tabs, Button } from 'antd'; + +import './index.scss'; + +type TargetKey = React.MouseEvent | React.KeyboardEvent | string; + +type CollectionTabType = { + label: string; + children: React.ReactNode; + key: string; +}; + +export const CollectionModal = () => { + const { + showCollectionModal, + setShowCollectionModal, + selectedCollection, + setSelectedCollection, + updaters, + allCollections, + allRelations, + } = useRepoCollectionContext(); + + const [activeKey, setActiveKey] = useState(); + const [items, setItems] = useState([]); + const [isInEditMode, setIsInEditMode] = useState(false); + + const editTab = ( +
+ + +
+ ); + + const onCreate = async (values: any, newRepoData: string[]) => { + if (isInEditMode) { + const updatedItems = items.map((item) => { + if (item.key === activeKey?.toString()) { + return { + label: values.collectionName, + children: , + key: values.collectionName, + }; + } + return item; + }); + setItems(updatedItems); + } else { + const newPanes = [...items]; + newPanes.push({ + label: values.collectionName, + children: , + 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); + + setSelectedCollection(values.collectionName); + setIsInEditMode(false); + }; + + 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', + 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); + updaters.removeCollection(targetKey.toString()); + setSelectedCollection(newActiveKey); + }, + onCancel() {}, + }); + }; + + const onEdit = ( + targetKey: React.MouseEvent | React.KeyboardEvent | string, + action: 'add' | 'remove' + ) => { + if (action === 'remove') remove(targetKey); + }; + + const tabItems = allCollections.map((collection) => { + const repoList = allRelations + .filter((relation) => relation.collectionId === collection.name) + .map((relation) => relation.repositoryId); + return { + label: collection.name, + children: , + key: collection.id, + }; + }); + + return ( +
+ { + setShowCollectionModal(false); + setSelectedCollection(undefined); + }} + footer={null} + width={'95%'} + style={{ + top: '10px', + bottom: '10px', + height: '95vh', + }} + bodyStyle={{ height: 'calc(95vh - 30px)' }} // 40px is the sum of top and bottom padding + > + + + { + setIsInEditMode(false); + }} + isEdit={isInEditMode} + collectionName={selectedCollection ? selectedCollection : ''} + collectionData={ + isInEditMode + ? allRelations + .filter( + (relation) => relation.collectionId === selectedCollection + ) + .map((relation) => relation.repositoryId) + : [''] + } + /> +
+ ); +}; diff --git a/src/pages/ContentScripts/features/repo-collection/charts/BarChart.tsx b/src/pages/ContentScripts/features/repo-collection/charts/BarChart.tsx new file mode 100644 index 00000000..696d124d --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/charts/BarChart.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useRef, useState } from 'react'; +import * as echarts from 'echarts'; +import generateDataByMonth from '../../../../../helpers/generate-data-by-month'; +import { getStars } from '../../../../../api/repo'; + +interface RawRepoData { + [date: string]: number; +} + +const LIGHT_THEME = { + FG_COLOR: '#24292f', + BG_COLOR: '#ffffff', + PALLET: ['#5470c6', '#91cc75'], +}; + +const DARK_THEME = { + FG_COLOR: '#c9d1d9', + BG_COLOR: '#0d1118', + PALLET: ['#58a6ff', '#3fb950'], +}; + +interface BarChartProps { + theme: 'light' | 'dark'; + height: number; + repoNames: string[]; + currentRepo?: string; +} + +const BarChart = (props: BarChartProps): JSX.Element => { + const { theme, height, repoNames, currentRepo } = props; + const divEL = useRef(null); + const TH = theme == 'light' ? LIGHT_THEME : DARK_THEME; + const [data, setData] = useState<{ [repo: string]: RawRepoData }>({}); + + const option: echarts.EChartsOption = { + tooltip: { + trigger: 'axis', + }, + // legend: { + // type: 'scroll', + // }, + grid: { + left: '5%', + right: '4%', + bottom: '3%', + containLabel: true, + }, + xAxis: { + type: 'time', + splitLine: { + show: false, + }, + axisLabel: { + color: TH.FG_COLOR, + formatter: { + year: '{yearStyle|{yy}}', + month: '{MMM}', + }, + rich: { + yearStyle: { + fontWeight: 'bold', + }, + }, + }, + }, + yAxis: { + type: 'value', + }, + dataZoom: [ + { + type: 'inside', + start: 0, + end: 100, + minValueSpan: 3600 * 24 * 1000 * 180, + }, + ], + series: BarChartSeries(data), // / Utilize the transformed series data + }; + console.log('bar', BarChartSeries(data)); + + useEffect(() => { + const fetchData = async () => { + for (const repo of repoNames) { + try { + //getStars() to fetch repository data + const starsData = await getStars(repo); + // Update Data/ + setData((prevData) => ({ ...prevData, [repo]: starsData })); + } catch (error) { + console.error(`Error fetching stars data for ${repo}:`, error); + // If the retrieval fails, set the data to an empty object + setData((prevData) => ({ ...prevData, [repo]: {} })); + } + } + }; + fetchData(); + }, []); + + useEffect(() => { + let chartDOM = divEL.current; + const TH = 'light' ? LIGHT_THEME : DARK_THEME; + + const instance = echarts.init(chartDOM as any); + instance.setOption(option); + instance.dispatchAction({ + type: 'highlight', + // seriesIndex: Number(currentRepo), + // dataIndex: Number(currentRepo), + name: repoNames[Number(currentRepo)], + seriesName: repoNames[Number(currentRepo)], + }); + return () => { + instance.dispose(); + }; + }, [data, currentRepo]); + + return
; +}; +const BarChartSeries = (data: { + [repo: string]: RawRepoData; +}): echarts.SeriesOption[] => + Object.entries(data).map(([repoName, repoData]) => ({ + name: repoName, + type: 'bar', + symbol: 'none', + data: getLastSixMonth(generateDataByMonth(repoData)), + emphasis: { + focus: 'series', + }, + yAxisIndex: 0, + triggerLineEvent: true, + })); + +const getLastSixMonth = (data: any[]) => + data.length > 6 ? data.slice(-6) : data; + +export default BarChart; diff --git a/src/pages/ContentScripts/features/repo-collection/charts/BoxplotChart.tsx b/src/pages/ContentScripts/features/repo-collection/charts/BoxplotChart.tsx new file mode 100644 index 00000000..65d9d45c --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/charts/BoxplotChart.tsx @@ -0,0 +1,159 @@ +import React, { useEffect, useRef, useState } from 'react'; +import * as echarts from 'echarts'; +import { getIssueResponseTime } from '../../../../../api/repo'; +import getNewestMonth from '../../../../../helpers/get-newest-month'; + +const LIGHT_THEME = { + FG_COLOR: '#24292f', + BG_COLOR: '#ffffff', + PALLET: ['#5470c6', '#91cc75'], +}; + +const DARK_THEME = { + FG_COLOR: '#c9d1d9', + BG_COLOR: '#0d1118', + PALLET: ['#58a6ff', '#3fb950'], +}; + +interface BarChartProps { + theme: 'light' | 'dark'; + height: number; + repoNames: string[]; + + currentRepo?: string; +} + +const BoxplotChart = (props: BarChartProps): JSX.Element => { + const { theme, height, repoNames, currentRepo } = props; + const divEL = useRef(null); + const TH = theme == 'light' ? LIGHT_THEME : DARK_THEME; + const [data, setData] = useState<{}>({}); + + console.log('Boxplot_data,', lastMonthRepoData(data)); + const option: echarts.EChartsOption = { + tooltip: { + trigger: 'item', + axisPointer: { + type: 'shadow', + }, + }, + legend: { + type: 'scroll', + }, + grid: { + left: '5%', + right: '4%', + bottom: '3%', + containLabel: true, + }, + xAxis: { + show: false, + type: 'category', + boundaryGap: true, + nameGap: 30, + splitArea: { + show: false, + }, + // data: Object.keys(data), + splitLine: { + show: false, + }, + // data: lastMonthRepoData(data).map((repo) => repo.name), + }, + yAxis: { + type: 'value', + name: 'value', + splitArea: { + show: true, + }, + }, + // dataZoom: [ + // { + // type: 'inside', + // start: 0, + // end: 100, + // minValueSpan: 3600 * 24 * 1000 * 180, + // }, + // ], + series: lastMonthRepoData(data).map((repoData) => { + return { + type: 'boxplot', + name: repoData.name, + data: [repoData], + }; + }), + // { + // type: 'boxplot', + // data: lastMonthRepoData(data), + // }, + }; + useEffect(() => { + const fetchData = async () => { + for (const repo of repoNames) { + try { + //getStars() to fetch repository data + const starsData = await getIssueResponseTime(repo); + // console.log('starsDatastarsData', starsData); + // Update Data/ + setData((prevData) => ({ ...prevData, [repo]: starsData })); + } catch (error) { + console.error(`Error fetching stars data for ${repo}:`, error); + // If the retrieval fails, set the data to an empty object + setData((prevData) => ({ ...prevData, [repo]: {} })); + } + } + }; + fetchData(); + // console.log('data', data); + }, []); + + useEffect(() => { + let chartDOM = divEL.current; + const TH = 'light' ? LIGHT_THEME : DARK_THEME; + + const instance = echarts.init(chartDOM as any); + // console.log('data', data); + // console.log('lastMonthRepoData', lastMonthRepoData(data)); + instance.setOption(option); + instance.dispatchAction({ + type: 'highlight', + seriesIndex: Number(currentRepo), + dataIndex: Number(currentRepo), + name: repoNames[Number(currentRepo)], + seriesName: repoNames[Number(currentRepo)], + }); + return () => { + instance.dispose(); + }; + }, [data, currentRepo]); + + return
; +}; + +//原始数据 =>各仓库最近一个月的数据[] +function lastMonthRepoData(repo_data: any) { + const resultArray = []; + const lastMonth = getNewestMonth(); + for (const repoName in repo_data) { + if (repo_data.hasOwnProperty(repoName)) { + const lastMonthData = { + name: repoName, + value: + repo_data[repoName][`avg`][lastMonth] !== undefined + ? Array.from( + { length: 5 }, + (_, q) => repo_data[repoName][`quantile_${q}`][lastMonth] + ) + : [null, null, null, null, null], + }; + + resultArray.push(lastMonthData); + // 将转换后的数据存储为对象,并添加到结果数组中 + // console.log('repoName', repoName); + // console.log('lastM', repo_data[repoName][`avg`][lastMonth]); + } + } + return resultArray; +} + +export default BoxplotChart; diff --git a/src/pages/ContentScripts/features/repo-collection/charts/CodeStackedBarChart.tsx b/src/pages/ContentScripts/features/repo-collection/charts/CodeStackedBarChart.tsx new file mode 100644 index 00000000..430a369c --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/charts/CodeStackedBarChart.tsx @@ -0,0 +1,179 @@ +import React, { useEffect, useRef, useState } from 'react'; +import * as echarts from 'echarts'; +import generateDataByMonth from '../../../../../helpers/generate-data-by-month'; +import { + getMergedCodeAddition, + getMergedCodeDeletion, +} from '../../../../../api/repo'; + +interface RawRepoData { + [date: string]: number; +} + +const LIGHT_THEME = { + FG_COLOR: '#24292f', + BG_COLOR: '#ffffff', + PALLET: ['#5470c6', '#91cc75'], +}; + +const DARK_THEME = { + FG_COLOR: '#c9d1d9', + BG_COLOR: '#0d1118', + PALLET: ['#58a6ff', '#3fb950'], +}; + +interface StackedBarChartProps { + theme: 'light' | 'dark'; + height: number; + repoNames: string[]; + + currentRepo?: string; +} + +const CodeStackedBarChart = (props: StackedBarChartProps): JSX.Element => { + const { theme, height, repoNames, currentRepo } = props; + const divEL = useRef(null); + const TH = theme == 'light' ? LIGHT_THEME : DARK_THEME; + const [data, setData] = useState<{ [repo: string]: RawRepoData }>({}); + + const option: echarts.EChartsOption = { + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross', // 设置 axisPointer 的类型为 cross,即十字准星 + }, + // formatter: (params: any) => { + // console.log('params',params); + + // return result; + // }, + }, + legend: { + type: 'scroll', + }, + grid: { + left: '5%', + right: '4%', + bottom: '3%', + containLabel: true, + }, + xAxis: { + type: 'time', + splitLine: { + show: false, + }, + axisLabel: { + color: TH.FG_COLOR, + formatter: { + year: '{yearStyle|{yy}}', + month: '{MMM}', + }, + rich: { + yearStyle: { + fontWeight: 'bold', + }, + }, + }, + }, + yAxis: { + type: 'value', + }, + dataZoom: [ + { + type: 'inside', + start: 0, + end: 100, + minValueSpan: 3600 * 24 * 1000 * 180, + }, + ], + series: MCDeletionSeries(data).concat(MCAdditionSeries(data)), // Series Data: Code Addition + Code CodeDeletion + }; + console.log( + 'BarChartSeries', + MCDeletionSeries(data).concat(MCAdditionSeries(data)) + ); + useEffect(() => { + const fetchData = async () => { + for (const repo of repoNames) { + try { + const MCAdditionData = await getMergedCodeAddition(repo); + const MCDeletionData = await getMergedCodeDeletion(repo); + const MergedCodeData = { + MCAdditionData: MCAdditionData, + MCDeletionData: MCDeletionData, + }; + setData((prevData) => ({ ...prevData, [repo]: MergedCodeData })); + } catch (error) { + console.error(`Error fetching stars data for ${repo}:`, error); + + setData((prevData) => ({ ...prevData, [repo]: {} })); + } + } + }; + fetchData(); + }, []); + console.log('data', data); + useEffect(() => { + let chartDOM = divEL.current; + const TH = 'light' ? LIGHT_THEME : DARK_THEME; + + const instance = echarts.init(chartDOM as any); + instance.setOption(option); + instance.dispatchAction({ + type: 'highlight', + // seriesIndex: Number(currentRepo), + // dataIndex: Number(currentRepo), + name: repoNames[Number(currentRepo)], + seriesName: repoNames[Number(currentRepo)], + }); + return () => { + instance.dispose(); + }; + }, [data, currentRepo]); + + return
; +}; + +//Series:各仓库代码增加行数 +const MCAdditionSeries = (data: { + [repo: string]: RawRepoData; +}): echarts.SeriesOption[] => + Object.entries(data).map(([repoName, repoData]) => ({ + name: repoName, + type: 'line', + areaStyle: {}, + smooth: true, + symbol: 'none', + stack: repoName, + data: generateDataByMonth(repoData.MCAdditionData), + emphasis: { + focus: 'series', + }, + yAxisIndex: 0, + triggerLineEvent: true, + })); + +//Series:各仓库代码删减行数 +const MCDeletionSeries = (data: { + [repo: string]: RawRepoData; +}): echarts.SeriesOption[] => + Object.entries(data).map(([repoName, repoData]) => ({ + name: repoName, + type: 'line', + areaStyle: {}, + symbol: 'none', + smooth: true, + stack: repoName, + data: generateDataByMonth(repoData.MCDeletionData).map((item) => [ + item[0], + -item[1], + ]), + emphasis: { + focus: 'series', + }, + yAxisIndex: 0, + triggerLineEvent: true, + })); + +//const getLastSixMonth = (data: any[]) => (data.length > 6 ? data.slice(-6) : data); +export default CodeStackedBarChart; diff --git a/src/pages/ContentScripts/features/repo-collection/charts/LineChart.tsx b/src/pages/ContentScripts/features/repo-collection/charts/LineChart.tsx new file mode 100644 index 00000000..508da19e --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/charts/LineChart.tsx @@ -0,0 +1,138 @@ +import React, { useEffect, useRef, useState } from 'react'; +import * as echarts from 'echarts'; +import generateDataByMonth from '../../../../../helpers/generate-data-by-month'; +import { getStars } from '../../../../../api/repo'; + +interface RawRepoData { + [date: string]: number; +} + +const LIGHT_THEME = { + FG_COLOR: '#24292f', + BG_COLOR: '#ffffff', + PALLET: ['#5470c6', '#91cc75'], +}; + +const DARK_THEME = { + FG_COLOR: '#c9d1d9', + BG_COLOR: '#0d1118', + PALLET: ['#58a6ff', '#3fb950'], +}; + +interface LineChartProps { + theme: 'light' | 'dark'; + height: number; + repoNames: string[]; + + currentRepo?: string; +} + +const LineChart = (props: LineChartProps): JSX.Element => { + const { theme, height, repoNames, currentRepo } = props; + + const divEL = useRef(null); + const TH = theme == 'light' ? LIGHT_THEME : DARK_THEME; + const [data, setData] = useState<{ [repo: string]: RawRepoData }>({}); + + const option: echarts.EChartsOption = { + tooltip: { + trigger: 'axis', + }, + legend: { + type: 'scroll', + }, + grid: { + left: '5%', + right: '4%', + bottom: '3%', + containLabel: true, + }, + xAxis: { + type: 'time', + splitLine: { + show: false, + }, + axisLabel: { + color: TH.FG_COLOR, + formatter: { + year: '{yearStyle|{yy}}', + month: '{MMM}', + }, + rich: { + yearStyle: { + fontWeight: 'bold', + }, + }, + }, + }, + yAxis: { + type: 'value', + }, + dataZoom: [ + { + type: 'slider', + }, + { + type: 'inside', + // start: 0, + // end: 100, + minValueSpan: 3600 * 24 * 1000 * 180, + }, + ], + series: LineChartSeries(data), + }; + + useEffect(() => { + const fetchData = async () => { + for (const repo of repoNames) { + try { + //getStars() to fetch repository data + const starsData = await getStars(repo); + // Update Data + setData((prevData) => ({ ...prevData, [repo]: starsData })); + } catch (error) { + console.error(`Error fetching stars data for ${repo}:`, error); + // If the retrieval fails, set the data to an empty object + setData((prevData) => ({ ...prevData, [repo]: {} })); + } + } + }; + fetchData(); + }, []); + + useEffect(() => { + let chartDOM = divEL.current; + const TH = 'light' ? LIGHT_THEME : DARK_THEME; + + const instance = echarts.init(chartDOM as any); + instance.setOption(option); + instance.dispatchAction({ + type: 'highlight', + // seriesIndex: Number(currentRepo), + // dataIndex: Number(currentRepo), + name: repoNames[Number(currentRepo)], + seriesName: repoNames[Number(currentRepo)], + }); + return () => { + instance.dispose(); + }; + }, [data, currentRepo]); + + return
; +}; +const LineChartSeries = (data: { + [repo: string]: RawRepoData; +}): echarts.SeriesOption[] => + Object.entries(data).map(([repoName, repoData]) => ({ + name: repoName, + type: 'line', + symbol: 'none', + smooth: true, + data: generateDataByMonth(repoData), + emphasis: { + focus: 'series', + }, + yAxisIndex: 0, + triggerLineEvent: true, + })); +export default LineChart; diff --git a/src/pages/ContentScripts/features/repo-collection/charts/NumericPanel.tsx b/src/pages/ContentScripts/features/repo-collection/charts/NumericPanel.tsx new file mode 100644 index 00000000..e60735bf --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/charts/NumericPanel.tsx @@ -0,0 +1,101 @@ +import React, { useEffect, useRef, useState } from 'react'; +import * as echarts from 'echarts'; +import generateDataByMonth from '../../../../../helpers/generate-data-by-month'; +import { getStars } from '../../../../../api/repo'; +import getNewestMonth from '../../../../../helpers/get-newest-month'; + +interface RawRepoData { + [date: string]: number; +} + +const LIGHT_THEME = { + FG_COLOR: '#24292f', + BG_COLOR: '#ffffff', + PALLET: ['#5470c6', '#91cc75'], +}; + +const DARK_THEME = { + FG_COLOR: '#c9d1d9', + BG_COLOR: '#0d1118', + PALLET: ['#58a6ff', '#3fb950'], +}; + +interface BarChartProps { + theme: 'light' | 'dark'; + height: number; + repoNames: string[]; + currentRepo?: string; +} + +const NumericPanel = (props: BarChartProps): JSX.Element => { + const { theme, height, repoNames, currentRepo } = props; + const divEL = useRef(null); + const TH = theme == 'light' ? LIGHT_THEME : DARK_THEME; + const [data, setData] = useState<{ [repo: string]: RawRepoData }>({}); + + const option: echarts.EChartsOption = { + graphic: [ + { + type: 'text', + left: 'center', + top: 'center', + style: { + fill: '#333', + text: valueSum(data).toString(), + font: 'bold 48px Arial', + }, + }, + ], + }; + + useEffect(() => { + const fetchData = async () => { + for (const repo of repoNames) { + try { + //getStars() to fetch repository data + const starsData = await getStars(repo); + // Update Data/ + setData((prevData) => ({ ...prevData, [repo]: starsData })); + } catch (error) { + console.error(`Error fetching stars data for ${repo}:`, error); + // If the retrieval fails, set the data to an empty object + setData((prevData) => ({ ...prevData, [repo]: {} })); + } + } + }; + fetchData(); + }, []); + + useEffect(() => { + let chartDOM = divEL.current; + const TH = 'light' ? LIGHT_THEME : DARK_THEME; + + const instance = echarts.init(chartDOM as any); + instance.setOption(option); + instance.dispatchAction({ + type: 'highlight', + // seriesIndex: Number(currentRepo), + // dataIndex: Number(currentRepo), + name: repoNames[Number(currentRepo)], + seriesName: repoNames[Number(currentRepo)], + }); + return () => { + instance.dispose(); + }; + }, [data, currentRepo]); + + return
; +}; + +function valueSum(data: Record): number { + return Object.values(data).reduce((sum, repoData) => { + const lastData = generateDataByMonth(repoData).at(-1); + const value = + lastData !== undefined && lastData[0] === getNewestMonth() + ? lastData[1] + : 0; + return sum + value; + }, 0); +} + +export default NumericPanel; diff --git a/src/pages/ContentScripts/features/repo-collection/charts/PieChart.tsx b/src/pages/ContentScripts/features/repo-collection/charts/PieChart.tsx new file mode 100644 index 00000000..f8af46e1 --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/charts/PieChart.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useRef, useState } from 'react'; +import * as echarts from 'echarts'; +import generateDataByMonth from '../../../../../helpers/generate-data-by-month'; +import { getParticipant } from '../../../../../api/repo'; +import getNewestMonth from '../../../../../helpers/get-newest-month'; + +interface RawRepoData { + [date: string]: number; +} + +const LIGHT_THEME = { + FG_COLOR: '#24292f', + BG_COLOR: '#ffffff', + PALLET: ['#5470c6', '#91cc75'], +}; + +const DARK_THEME = { + FG_COLOR: '#c9d1d9', + BG_COLOR: '#0d1118', + PALLET: ['#58a6ff', '#3fb950'], +}; + +interface PieChartProps { + theme: 'light' | 'dark'; + height: number; + repoNames: string[]; + + currentRepo?: string; +} + +const PieChart = (props: PieChartProps): JSX.Element => { + const { theme, height, repoNames, currentRepo } = props; + const divEL = useRef(null); + const TH = theme == 'light' ? LIGHT_THEME : DARK_THEME; + const [data, setData] = useState<{ [repo: string]: RawRepoData }>({}); + + const option: echarts.EChartsOption = { + tooltip: { + trigger: 'item', + }, + legend: { + type: 'scroll', + }, + + series: [ + { + type: 'pie', + radius: ['40%', '70%'], + avoidLabelOverlap: false, + itemStyle: { + borderRadius: 10, + borderColor: '#fff', + borderWidth: 2, + }, + label: { + show: false, + position: 'center', + }, + emphasis: { + label: { + show: true, + fontSize: 20, + fontWeight: 'bold', + }, + }, + data: PieChartData(data), + }, + ], + }; + + useEffect(() => { + const fetchData = async () => { + for (const repo of repoNames) { + try { + //getStars() to fetch repository data + const starsData = await getParticipant(repo); + // Update Data + setData((prevData) => ({ ...prevData, [repo]: starsData })); + } catch (error) { + console.error(`Error fetching stars data for ${repo}:`, error); + // If the retrieval fails, set the data to an empty object + setData((prevData) => ({ ...prevData, [repo]: {} })); + } + } + }; + fetchData(); + }, []); + + useEffect(() => { + let chartDOM = divEL.current; + const TH = 'light' ? LIGHT_THEME : DARK_THEME; + + const instance = echarts.init(chartDOM as any); + instance.setOption(option); + console.log('pieChart,currentRepo', currentRepo); + instance.dispatchAction({ + type: 'highlight', + // seriesIndex: 0, + dataIndex: Number(currentRepo), + }); + return () => { + instance.dispose(); + }; + }, [data, currentRepo]); + + return
; +}; + +// Retrieve data for the current month +const PieChartData = (data: { [repo: string]: RawRepoData }) => + Object.entries(data).map(([repoName, repoData]) => { + const lastData = generateDataByMonth(repoData).at(-1); + return { + name: repoName, + value: + lastData !== undefined && lastData[0] === getNewestMonth() + ? lastData[1] + : 0, + }; + }); + +export default PieChart; diff --git a/src/pages/ContentScripts/features/repo-collection/charts/SankeyChart.tsx b/src/pages/ContentScripts/features/repo-collection/charts/SankeyChart.tsx new file mode 100644 index 00000000..02c15c1f --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/charts/SankeyChart.tsx @@ -0,0 +1,159 @@ +import React, { useEffect, useRef, useState } from 'react'; +import * as echarts from 'echarts'; +import getNewestMonth from '../../../../../helpers/get-newest-month'; +import { getActivityDetails } from '../../../../../api/repo'; + +const LIGHT_THEME = { + FG_COLOR: '#24292f', + BG_COLOR: '#ffffff', + PALLET: ['#5470c6', '#91cc75'], +}; + +const DARK_THEME = { + FG_COLOR: '#c9d1d9', + BG_COLOR: '#0d1118', + PALLET: ['#58a6ff', '#3fb950'], +}; + +interface SankeyChartProps { + theme: 'light' | 'dark'; + height: number; + repoNames: string[]; + + currentRepo?: string; +} + +const SankeyChart = (props: SankeyChartProps): JSX.Element => { + const { theme, height, repoNames, currentRepo } = props; + const divEL = useRef(null); + const [data, setData] = useState<{ [repoName: string]: RepoData }>({}); + + const option: echarts.EChartsOption = { + tooltip: { + trigger: 'item', + triggerOn: 'mousemove', + }, + // legend: { + // type: 'scroll', + // }, + animation: false, + grid: { + left: '2%', + right: '10%', + bottom: '3%', + // containLabel: true, + }, + series: [ + { + type: 'sankey', + // bottom: '10%', + emphasis: { + focus: 'adjacency', + }, + data: lastMonthData(data).nodes, + links: lastMonthData(data).links, + // orient: "vertical", + // label: { + // position: "top" + // }, + lineStyle: { + color: 'source', + curveness: 0.5, + }, + }, + ], + }; + + useEffect(() => { + const fetchData = async () => { + for (const repo of repoNames) { + try { + //getStars() to fetch repository data + const starsData = await getActivityDetails(repo); + // Update Data/ + setData((prevData) => ({ ...prevData, [repo]: starsData })); + } catch (error) { + console.error(`Error fetching stars data for ${repo}:`, error); + // If the retrieval fails, set the data to an empty object + setData((prevData) => ({ ...prevData, [repo]: {} })); + } + } + }; + fetchData(); + }, []); + + useEffect(() => { + let chartDOM = divEL.current; + const TH = 'light' ? LIGHT_THEME : DARK_THEME; + + const instance = echarts.init(chartDOM as any); + // console.log('sankeydata', data); + // console.log('lastMonthData', lastMonthData(data)); + instance.setOption(option); + instance.dispatchAction({ + type: 'highlight', + // seriesIndex: Number(currentRepo), + // dataIndex: Number(currentRepo), + name: repoNames[Number(currentRepo)], + seriesName: repoNames[Number(currentRepo)], + }); + return () => { + instance.dispose(); + }; + }, [data, currentRepo]); + + return
; +}; + +interface RepoData { + [month: string]: [string, number][]; +} + +interface DataNode { + name: string; +} + +interface DataLink { + source: string; + target: string; + value: number; +} + +interface LastMonthData { + nodes: DataNode[]; + links: DataLink[]; +} + +function lastMonthData(repo_data: { + [repoName: string]: RepoData; +}): LastMonthData { + const data: LastMonthData = { + nodes: [], + links: [], + }; + const userSet = new Set(); + const newestMonth = getNewestMonth(); + for (const [repoName, repoData] of Object.entries(repo_data)) { + const monthData = repoData[newestMonth]; + if (monthData) { + monthData.forEach(([userName, value]) => { + const link: DataLink = { + source: repoName, + target: userName, + value: value, + }; + userSet.add(userName); + data.links.push(link); + }); + } + } + data.nodes = [ + ...Object.keys(repo_data).map((repoName) => ({ name: repoName })), + ...Array.from(userSet).map((userName) => ({ name: userName })), + ]; + // console.log(data.nodes); + // console.log(data.links); + return data; +} + +export default SankeyChart; diff --git a/src/pages/ContentScripts/features/repo-collection/charts/StackedBarChart.tsx b/src/pages/ContentScripts/features/repo-collection/charts/StackedBarChart.tsx new file mode 100644 index 00000000..8d4ae6df --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/charts/StackedBarChart.tsx @@ -0,0 +1,165 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import * as echarts from 'echarts'; +import generateDataByMonth from '../../../../../helpers/generate-data-by-month'; +import { getStars } from '../../../../../api/repo'; + +interface RawRepoData { + [date: string]: number; +} + +const LIGHT_THEME = { + FG_COLOR: '#24292f', + BG_COLOR: '#ffffff', + PALLET: ['#5470c6', '#91cc75'], +}; + +const DARK_THEME = { + FG_COLOR: '#c9d1d9', + BG_COLOR: '#0d1118', + PALLET: ['#58a6ff', '#3fb950'], +}; + +interface StackedBarChartProps { + theme: 'light' | 'dark'; + height: number; + repoNames: string[]; + + currentRepo?: string; +} + +const StackedBarChart = (props: StackedBarChartProps): JSX.Element => { + const { theme, height, repoNames, currentRepo } = props; + const divEL = useRef(null); + const TH = theme == 'light' ? LIGHT_THEME : DARK_THEME; + const [data, setData] = useState<{ [repo: string]: RawRepoData }>({}); + + // Fetch data for the specified repositories + useEffect(() => { + const fetchData = async () => { + const repoData: { [repo: string]: RawRepoData } = {}; + for (const repo of repoNames) { + try { + repoData[repo] = await getStars(repo); + } catch (error) { + console.error(`Error fetching stars data for ${repo}:`, error); + repoData[repo] = {}; + } + } + setData(repoData); + }; + fetchData(); + }, [repoNames]); + + // Preprocess the data + const preprocessedData = useMemo(() => addPreviousMonth(data), [data]); + + const option: echarts.EChartsOption = { + tooltip: { + trigger: 'axis', + }, + legend: { + type: 'scroll', + }, + grid: { + left: '5%', + right: '4%', + bottom: '3%', + containLabel: true, + }, + xAxis: { + type: 'time', + splitLine: { + show: false, + }, + axisLabel: { + color: TH.FG_COLOR, + formatter: { + year: '{yearStyle|{yy}}', + month: '{MMM}', + }, + rich: { + yearStyle: { + fontWeight: 'bold', + }, + }, + }, + }, + yAxis: { + type: 'value', + }, + dataZoom: [ + { + type: 'inside', + start: 0, + end: 100, + minValueSpan: 3600 * 24 * 1000 * 180, + }, + ], + series: StarSeries(preprocessedData), + }; + //console.log('StackedBarChartseries', StarSeries(preprocessedData)); + + useEffect(() => { + let chartDOM = divEL.current; + + const instance = echarts.init(chartDOM as any); + instance.setOption(option); + instance.dispatchAction({ + type: 'highlight', + seriesIndex: Number(currentRepo), + dataIndex: Number(currentRepo), + name: repoNames[Number(currentRepo)], + seriesName: repoNames[Number(currentRepo)], + }); + return () => { + instance.dispose(); + }; + }, [option, currentRepo]); + + return
; +}; + +// Preprocess data by adding previous month data +const addPreviousMonth = (data: { [repo: string]: RawRepoData }) => { + const preprocessedData: { [repo: string]: [string, number][] } = {}; + let maxLength = 0; + + // Iterate through the data of each repository + for (const [repoName, repoData] of Object.entries(data)) { + const generatedData = generateDataByMonth(repoData); + preprocessedData[repoName] = generatedData; + + // Update the maximum length + maxLength = Math.max(maxLength, generatedData.length); + } + // // Fill in arrays with months + for (const repoData of Object.values(preprocessedData)) { + while (repoData.length < maxLength) { + const [year, month] = repoData[0][0].split('-'); + const previousMonth = new Date(parseInt(year), parseInt(month) - 1, 1) + .toISOString() + .slice(0, 7); + repoData.unshift([previousMonth, 0]); + } + } + + return preprocessedData; +}; + +// Generate chart series for each repository +const StarSeries = (data: { + [repo: string]: [string, number][]; +}): echarts.SeriesOption[] => + Object.entries(data).map(([repoName, repoData]) => ({ + name: repoName, + type: 'bar', + stack: 'total', + // emphasis: emphasisStyle, + data: repoData, + emphasis: { + focus: 'series', + }, + triggerLineEvent: true, + })); + +export default StackedBarChart; 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..3a3d0138 --- /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 [showCollectionModal, setShowCollectionModal] = useState(false); + const [selectedCollection, setSelectedCollection] = useState(); + + return { + currentRepositoryId, + currentRepositoryCollections, + allCollections, + allRelations, + updaters, + + hideCollectionList, + setHideCollectionList, + hideAddToCollections, + setHideAddToCollections, + + showCollectionModal, + setShowCollectionModal, + + 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..a57e1281 --- /dev/null +++ b/src/pages/ContentScripts/features/repo-collection/view.tsx @@ -0,0 +1,20 @@ +import { CollectionButton } from './CollectionButton'; +import { CollectionModal } 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 bab274a3..9288402a 100644 --- a/src/pages/ContentScripts/index.ts +++ b/src/pages/ContentScripts/index.ts @@ -13,3 +13,4 @@ import './features/repo-networks'; import './features/developer-networks'; import './features/oss-gpt'; import './features/repo-activity-racing-bar'; +import './features/repo-collection'; diff --git a/src/pages/Popup/Popup.tsx b/src/pages/Popup/Popup.tsx index 8f39b0d6..d30998ff 100644 --- a/src/pages/Popup/Popup.tsx +++ b/src/pages/Popup/Popup.tsx @@ -1,15 +1,34 @@ -import React from 'react'; +import { number } from 'echarts'; +import React, { useState } from 'react'; +import { Space, Button } from 'antd'; export default function Popup() { + // refer: https://developer.chrome.com/docs/extensions/mv3/messaging/#simple + const openFeatureInContentScript = async () => { + const [tab] = await chrome.tabs.query({ + active: true, + lastFocusedWindow: true, + }); + const response = await chrome.tabs.sendMessage(tab.id!, { + greeting: 'demo', + }); + }; return ( -
- +
+ + + +
); } diff --git a/yarn.lock b/yarn.lock index 2443a626..7eb9c959 100644 --- a/yarn.lock +++ b/yarn.lock @@ -330,7 +330,7 @@ "@babel/plugin-proposal-class-properties@^7.16.7": version "7.18.6" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz#b110f59741895f7ec21a6fff696ec46265c446a3" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz#b110f59741895f7ec21a6fff696ec46265c446a3" integrity sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ== dependencies: "@babel/helper-create-class-features-plugin" "^7.18.6" @@ -1411,7 +1411,7 @@ "@nodelib/fs.walk@^1.2.3": version "1.2.8" - resolved "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== dependencies: "@nodelib/fs.scandir" "2.1.5" @@ -2730,7 +2730,7 @@ commander@^2.20.0: commander@^7.0.0: version "7.2.0" - resolved "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" + resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== commander@^8.3.0: @@ -2788,6 +2788,11 @@ connect-history-api-fallback@^2.0.0: resolved "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz#647264845251a0daf25b97ce87834cace0f5f1c8" integrity sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA== +constate@^3.3.2: + version "3.3.2" + resolved "https://registry.npmjs.org/constate/-/constate-3.3.2.tgz#a6cd2f3c203da2cb863f47d22a330b833936c449" + integrity sha512-ZnEWiwU6QUTil41D5EGpA7pbqAPGvnR9kBjko8DzVIxpC60mdNKrP568tT5WLJPAxAOtJqJw60+h79ot/Uz1+Q== + content-disposition@0.5.4: version "0.5.4" resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" @@ -2896,7 +2901,7 @@ crx@^5.0.1: css-loader@^6.6.0: version "6.8.1" - resolved "https://registry.npmjs.org/css-loader/-/css-loader-6.8.1.tgz#0f8f52699f60f5e679eab4ec0fcd68b8e8a50a88" + resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.8.1.tgz#0f8f52699f60f5e679eab4ec0fcd68b8e8a50a88" integrity sha512-xDAXtEVGlD0gJ07iclwWVkLoZOpEvAWaSyf6W18S2pOC//K8+qUDIx8IIT3D+HjnmkJPQeesOPv5aiUaJsCM2g== dependencies: icss-utils "^5.1.0" @@ -3529,7 +3534,7 @@ fs.realpath@^1.0.0: fsevents@~2.3.2: version "2.3.2" - resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== function-bind@^1.1.1: @@ -3632,7 +3637,7 @@ globals@^11.1.0: globby@^11.0.1: version "11.1.0" - resolved "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== dependencies: array-union "^2.1.0" @@ -4108,7 +4113,7 @@ jest-get-type@^27.5.1: jest-matcher-utils@^27.0.0: version "27.5.1" - resolved "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz#9c0cdbda8245bc22d2331729d1091308b40cf8ab" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz#9c0cdbda8245bc22d2331729d1091308b40cf8ab" integrity sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw== dependencies: chalk "^4.0.0" @@ -4488,7 +4493,7 @@ min-indent@^1.0.1: mini-css-extract-plugin@^2.7.2: version "2.7.6" - resolved "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.6.tgz#282a3d38863fddcd2e0c220aaed5b90bc156564d" + resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.6.tgz#282a3d38863fddcd2e0c220aaed5b90bc156564d" integrity sha512-Qk7HcgaPkGG6eD77mLvZS1nmxlao3j+9PkrT9Uc7HAE1id3F41+DdBRYRYkbyfNRGzm8/YWtzhw7nVPmwhqTQw== dependencies: schema-utils "^4.0.0" @@ -4791,7 +4796,7 @@ parse-data-uri@^0.2.0: parse5@^6.0.1: version "6.0.1" - resolved "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== parse5@^7.0.0: @@ -4874,7 +4879,7 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: pify@^2.0.0: version "2.3.0" - resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== pify@^4.0.1: @@ -5002,7 +5007,7 @@ process@^0.11.10: prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" - resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== dependencies: loose-envify "^1.4.0" @@ -5788,7 +5793,7 @@ safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, sass-loader@^12.4.0: version "12.6.0" - resolved "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz#5148362c8e2cdd4b950f3c63ac5d16dbfed37bcb" + resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-12.6.0.tgz#5148362c8e2cdd4b950f3c63ac5d16dbfed37bcb" integrity sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA== dependencies: klona "^2.0.4" @@ -6000,7 +6005,7 @@ sockjs@^0.3.24: source-map-loader@^3.0.1: version "3.0.2" - resolved "https://registry.npmjs.org/source-map-loader/-/source-map-loader-3.0.2.tgz#af23192f9b344daa729f6772933194cc5fa54fee" + resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-3.0.2.tgz#af23192f9b344daa729f6772933194cc5fa54fee" integrity sha512-BokxPoLjyl3iOrgkWaakaxqnelAJSS+0V+De0kKIq6lyWrXuiPgYTGp6z3iHmqljKAaLXwZa+ctD8GccRJeVvg== dependencies: abab "^2.0.5" @@ -6170,7 +6175,7 @@ tar-stream@^2.1.0: terser-webpack-plugin@^5.3.1, terser-webpack-plugin@^5.3.7: version "5.3.9" - resolved "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz#832536999c51b46d468067f9e37662a3b96adfe1" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz#832536999c51b46d468067f9e37662a3b96adfe1" integrity sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA== dependencies: "@jridgewell/trace-mapping" "^0.3.17"