1
0
mirror of https://github.com/XFox111/TabsAsideExtension.git synced 2026-04-22 07:58:01 +03:00

!feat: major 3.0 release candidate

This commit is contained in:
2025-05-03 23:59:43 +03:00
parent dbc8c7fd4d
commit 39793a38c3
143 changed files with 14277 additions and 0 deletions
@@ -0,0 +1,51 @@
import { makeStyles, tokens } from "@fluentui/react-components";
export const useStyles_CollectionListView = makeStyles({
root:
{
display: "flex",
flexFlow: "column",
gap: tokens.spacingVerticalM,
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalS}`,
overflowX: "hidden",
overflowY: "auto"
},
collectionList:
{
display: "flex",
flexFlow: "column",
gap: tokens.spacingVerticalM
},
searchBar:
{
boxShadow: tokens.shadow2
},
emptySearch:
{
display: "flex",
flexFlow: "column",
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
gap: tokens.spacingVerticalS
},
empty:
{
display: "flex",
flexFlow: "column",
alignItems: "center",
justifyContent: "center",
gap: tokens.spacingVerticalS,
padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalM}`,
color: tokens.colorNeutralForeground2
},
msgBar:
{
flex: "none"
},
listView:
{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(360px, 1fr))"
}
});
@@ -0,0 +1,149 @@
import CollectionView from "@/entrypoints/sidepanel/components/CollectionView";
import GroupView from "@/entrypoints/sidepanel/components/GroupView";
import { DndItem } from "@/entrypoints/sidepanel/hooks/useDndItem";
import CloudIssueMessages from "@/entrypoints/sidepanel/layouts/collections/messages/CloudIssueMessages";
import CtaMessage from "@/entrypoints/sidepanel/layouts/collections/messages/CtaMessage";
import filterCollections, { CollectionFilterType } from "@/entrypoints/sidepanel/utils/filterCollections";
import sortCollections from "@/entrypoints/sidepanel/utils/sortCollections";
import useSettings from "@/hooks/useSettings";
import { CollectionItem } from "@/models/CollectionModels";
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
import { rectSortingStrategy, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { Body1, Button, Caption1, mergeClasses, Subtitle2 } from "@fluentui/react-components";
import { ArrowUndo20Regular, SearchInfo24Regular, Sparkle48Regular } from "@fluentui/react-icons";
import { ReactElement } from "react";
import TabView from "../../components/TabView";
import CollectionContext from "../../contexts/CollectionContext";
import { useCollections } from "../../contexts/CollectionsProvider";
import applyReorder from "../../utils/dnd/applyReorder";
import { collisionDetector } from "../../utils/dnd/collisionDetector";
import { useStyles_CollectionListView } from "./CollectionListView.styles";
import SearchBar from "./SearchBar";
import StorageCapacityIssueMessage from "./messages/StorageCapacityIssueMessage";
export default function CollectionListView(): ReactElement
{
const { tilesView, updateCollections, collections } = useCollections();
const [sortMode, setSortMode] = useSettings("sortMode");
const [query, setQuery] = useState<string>("");
const [colors, setColors] = useState<CollectionFilterType["colors"]>([]);
const [active, setActive] = useState<DndItem | null>(null);
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { delay: 100, tolerance: 0 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 300, tolerance: 20 } })
);
const resultList = useMemo(
() => sortCollections(filterCollections(collections, { query, colors }), sortMode),
[query, colors, sortMode, collections]
);
const cls = useStyles_CollectionListView();
const resetFilter = useCallback(() =>
{
setQuery("");
setColors([]);
}, []);
const handleDragStart = (event: DragStartEvent): void =>
{
setActive(event.active.data.current as DndItem);
};
const handleDragEnd = (args: DragEndEvent): void =>
{
setActive(null);
const result: CollectionItem[] | null = applyReorder(resultList, args);
if (result !== null)
{
updateCollections(result);
if (sortMode !== "custom")
setSortMode("custom");
}
};
if (sortMode === null || collections === null)
return <></>;
if (collections.length < 1)
return (
<article className={ cls.empty }>
<Sparkle48Regular />
<Subtitle2 align="center">{ i18n.t("main.list.empty.title") }</Subtitle2>
<Caption1 align="center">{ i18n.t("main.list.empty.message") }</Caption1>
</article>
);
return (
<article className={ cls.root }>
<SearchBar
query={ query } onQueryChange={ setQuery }
filter={ colors } onFilterChange={ setColors }
sort={ sortMode } onSortChange={ setSortMode }
onReset={ resetFilter } />
<CtaMessage className={ cls.msgBar } />
<StorageCapacityIssueMessage className={ cls.msgBar } />
<CloudIssueMessages className={ cls.msgBar } />
{ resultList.length < 1 ?
<div className={ cls.emptySearch }>
<SearchInfo24Regular />
<Subtitle2>{ i18n.t("main.list.empty_search.title") }</Subtitle2>
<Body1>{ i18n.t("main.list.empty_search.message") }</Body1>
<Button appearance="subtle" icon={ <ArrowUndo20Regular /> } onClick={ resetFilter }>
{ i18n.t("common.actions.reset_filters") }
</Button>
</div>
:
<section className={ mergeClasses(cls.collectionList, !tilesView && cls.listView) }>
<DndContext
sensors={ sensors }
collisionDetection={ collisionDetector(!tilesView) }
onDragStart={ handleDragStart }
onDragEnd={ handleDragEnd }
>
<SortableContext
items={ resultList.map((_, index) => index.toString()) }
strategy={ tilesView ? verticalListSortingStrategy : rectSortingStrategy }
>
{ resultList.map((collection, index) =>
<CollectionView key={ index } collection={ collection } index={ index } />
) }
</SortableContext>
<DragOverlay dropAnimation={ null }>
{ active &&
<>
{ active.item.type === "collection" &&
<CollectionView collection={ active.item } index={ -1 } dragOverlay />
}
{ active.item.type === "group" &&
<CollectionContext.Provider
value={ {
tabCount: 0,
collectionIndex: active.indices[0],
collection: resultList[active.indices[0]],
hasPinnedGroup: true
} }
>
<GroupView group={ active.item } indices={ [-1] } dragOverlay />
</CollectionContext.Provider>
}
{ active.item.type === "tab" &&
<TabView tab={ active.item } indices={ [-1] } dragOverlay />
}
</>
}
</DragOverlay>
</DndContext>
</section>
}
</article>
);
}
@@ -0,0 +1,71 @@
import { useGroupColors } from "@/hooks/useGroupColors";
import * as fui from "@fluentui/react-components";
import * as ic from "@fluentui/react-icons";
import { CollectionFilterType } from "../../utils/filterCollections";
export default function FilterCollectionsButton({ value, onChange }: FilterCollectionsButtonProps): React.ReactElement
{
const cls = useStyles();
const colorCls = useGroupColors();
const ColorFilterIcon = ic.bundleIcon(ic.Color20Filled, ic.Color20Regular);
const ColorIcon = ic.bundleIcon(ic.Circle20Filled, ic.CircleHalfFill20Regular);
const NoColorIcon = ic.bundleIcon(ic.CircleOffFilled, ic.CircleOffRegular);
const AnyColorIcon = ic.bundleIcon(ic.PhotoFilter20Filled, ic.PhotoFilter20Regular);
return (
<fui.Menu
checkedValues={ !value || value.length < 1 ? { default: ["any"] } : { colors: value } }
onCheckedValueChange={ (_, e) =>
onChange?.(e.checkedItems.includes("any") ? [] : e.checkedItems as CollectionFilterType["colors"])
}
>
<fui.Tooltip relationship="label" content={ i18n.t("main.list.searchbar.filter") }>
<fui.MenuTrigger disableButtonEnhancement>
<fui.Button appearance="subtle" icon={ <ColorFilterIcon /> } />
</fui.MenuTrigger>
</fui.Tooltip>
<fui.MenuPopover>
<fui.MenuList>
<fui.MenuItemCheckbox name="default" value="any" icon={ <AnyColorIcon /> }>
{ i18n.t("colors.any") }
</fui.MenuItemCheckbox>
<fui.MenuDivider />
<fui.MenuItemCheckbox name="colors" value="none" icon={ <NoColorIcon /> }>
{ i18n.t("colors.none") }
</fui.MenuItemCheckbox>
{ Object.keys(colorCls).map(i =>
<fui.MenuItemCheckbox
key={ i } name="colors" value={ i }
icon={
<ColorIcon
className={ fui.mergeClasses(
cls.colorIcon,
colorCls[i as chrome.tabGroups.ColorEnum]
) } />
}
>
{ i18n.t(`colors.${i as chrome.tabGroups.ColorEnum}`) }
</fui.MenuItemCheckbox>
) }
</fui.MenuList>
</fui.MenuPopover>
</fui.Menu>
);
}
export type FilterCollectionsButtonProps =
{
value?: CollectionFilterType["colors"];
onChange?: (value: CollectionFilterType["colors"]) => void;
};
const useStyles = fui.makeStyles({
colorIcon:
{
color: "var(--border)"
}
});
@@ -0,0 +1,51 @@
import { Button, Input, makeStyles, tokens, Tooltip } from "@fluentui/react-components";
import { ArrowUndo20Filled, ArrowUndo20Regular, bundleIcon, Search20Regular } from "@fluentui/react-icons";
import { CollectionFilterType } from "../../utils/filterCollections";
import { CollectionSortMode } from "../../utils/sortCollections";
import FilterCollectionsButton from "./FilterCollectionsButton";
import SortCollectionsButton from "./SortCollectionsButton";
export default function SearchBar(props: SearchBarProps): React.ReactElement
{
const cls = useStyles();
const ResetIcon = bundleIcon(ArrowUndo20Filled, ArrowUndo20Regular);
return (
<Input
className={ cls.root }
appearance="filled-lighter"
contentBefore={ <Search20Regular /> }
placeholder={ i18n.t("main.list.searchbar.title") }
value={ props.query } onChange={ (_, e) => props.onQueryChange?.(e.value) }
contentAfter={
<>
{ (props.query || (props.filter && props.filter.length > 0)) &&
<Tooltip relationship="label" content={ i18n.t("common.actions.reset_filters") }>
<Button appearance="subtle" icon={ <ResetIcon /> } onClick={ props.onReset } />
</Tooltip>
}
<FilterCollectionsButton value={ props.filter } onChange={ props.onFilterChange } />
<SortCollectionsButton value={ props.sort } onChange={ props.onSortChange } />
</>
} />
);
}
export type SearchBarProps =
{
query?: string;
onQueryChange?: (query: string) => void;
filter?: CollectionFilterType["colors"];
onFilterChange?: (filter: CollectionFilterType["colors"]) => void;
sort?: CollectionSortMode;
onSortChange?: (sort: CollectionSortMode) => void;
onReset?: () => void;
};
const useStyles = makeStyles({
root:
{
boxShadow: tokens.shadow2
}
});
@@ -0,0 +1,46 @@
import { CollectionSortMode } from "@/entrypoints/sidepanel/utils/sortCollections";
import { Button, Menu, MenuItemRadio, MenuList, MenuPopover, MenuTrigger, Tooltip } from "@fluentui/react-components";
import * as ic from "@fluentui/react-icons";
export default function SortCollectionsButton({ value, onChange }: SortCollectionsButtonProps): React.ReactElement
{
const ColorSortIcon = ic.bundleIcon(ic.ArrowSort20Filled, ic.ArrowSort20Regular);
return (
<Menu
checkedValues={ { sort: value ? [value] : [] } }
onCheckedValueChange={ (_, e) => onChange?.(e.checkedItems[0] as CollectionSortMode) }
>
<Tooltip relationship="label" content={ i18n.t("main.list.searchbar.sort.title") }>
<MenuTrigger disableButtonEnhancement>
<Button appearance="subtle" icon={ <ColorSortIcon /> } />
</MenuTrigger>
</Tooltip>
<MenuPopover>
<MenuList>
{ Object.entries(sortIcons).map(([key, Icon]) =>
<MenuItemRadio key={ key } name="sort" value={ key } icon={ <Icon /> }>
{ i18n.t(`main.list.searchbar.sort.options.${key as CollectionSortMode}`) }
</MenuItemRadio>
) }
</MenuList>
</MenuPopover>
</Menu>
);
}
export type SortCollectionsButtonProps =
{
value?: CollectionSortMode | null;
onChange?: (value: CollectionSortMode) => void;
};
const sortIcons: Record<CollectionSortMode, ic.FluentIcon> =
{
newest: ic.bundleIcon(ic.Sparkle20Filled, ic.Sparkle20Regular),
oldest: ic.bundleIcon(ic.History20Filled, ic.History20Regular),
ascending: ic.bundleIcon(ic.TextSortAscending20Filled, ic.TextSortAscending20Regular),
descending: ic.bundleIcon(ic.TextSortDescending20Filled, ic.TextSortDescending20Regular),
custom: ic.bundleIcon(ic.Star20Filled, ic.Star20Regular)
};
@@ -0,0 +1,50 @@
import resolveConflict from "@/features/collectionStorage/utils/resolveConflict";
import { Button, MessageBar, MessageBarActions, MessageBarBody, MessageBarProps, MessageBarTitle } from "@fluentui/react-components";
import { ArrowUpload20Regular, CloudArrowDown20Regular, Wrench20Regular } from "@fluentui/react-icons";
import { useCollections } from "../../../contexts/CollectionsProvider";
export default function CloudIssueMessages(props: MessageBarProps): React.ReactElement
{
const { cloudIssue, refreshCollections } = useCollections();
const overrideStorageWith = async (source: "local" | "sync") =>
{
await resolveConflict(source);
await refreshCollections();
};
return (
<>
{ cloudIssue === "parse_error" &&
<MessageBar intent="error" layout="multiline" { ...props }>
<MessageBarBody>
<MessageBarTitle>{ i18n.t("parse_error_message.title") }</MessageBarTitle>
{ i18n.t("parse_error_message.message") }
</MessageBarBody>
<MessageBarActions>
<Button icon={ <Wrench20Regular /> } onClick={ () => overrideStorageWith("local") }>
{ i18n.t("parse_error_message.action") }
</Button>
</MessageBarActions>
</MessageBar>
}
{ cloudIssue === "merge_conflict" &&
<MessageBar intent="warning" layout="multiline" { ...props }>
<MessageBarBody>
<MessageBarTitle>{ i18n.t("merge_conflict_message.title") }</MessageBarTitle>
{ i18n.t("merge_conflict_message.message") }
</MessageBarBody>
<MessageBarActions>
<Button icon={ <ArrowUpload20Regular /> } onClick={ () => overrideStorageWith("local") }>
{ i18n.t("merge_conflict_message.accept_local") }
</Button>
<Button icon={ <CloudArrowDown20Regular /> } onClick={ () => overrideStorageWith("sync") }>
{ i18n.t("merge_conflict_message.accept_cloud") }
</Button>
</MessageBarActions>
</MessageBar>
}
</>
);
}
@@ -0,0 +1,60 @@
import { BuyMeACoffee20Regular } from "@/assets/BuyMeACoffee20";
import { buyMeACoffeeLink, storeLink } from "@/data/links";
import { useBmcStyles } from "@/hooks/useBmcStyles";
import extLink from "@/utils/extLink";
import { Button, Link, MessageBar, MessageBarActions, MessageBarBody, MessageBarProps, MessageBarTitle } from "@fluentui/react-components";
import { DismissRegular, HeartFilled } from "@fluentui/react-icons";
import { ReactElement } from "react";
export default function CtaMessage(props: MessageBarProps): ReactElement
{
const [counter, setCounter] = useState<number>(0);
const bmcCls = useBmcStyles();
useEffect(() =>
{
ctaCounter.getValue().then(c =>
{
if (c >= 0)
{
setCounter(c);
ctaCounter.setValue(c + 1);
}
});
}, []);
const resetCounter = async (counter: number) =>
{
await ctaCounter.setValue(counter);
setCounter(counter);
};
if (counter < 50)
return <></>;
return (
<MessageBar layout="multiline" icon={ <HeartFilled color="red" /> } { ...props }>
<MessageBarBody>
<MessageBarTitle>{ i18n.t("cta_message.title") }</MessageBarTitle>
{ i18n.t("cta_message.message") } <Link { ...extLink(storeLink) }>{ i18n.t("cta_message.feedback") }</Link>
</MessageBarBody>
<MessageBarActions
containerAction={
<Button icon={ <DismissRegular /> } appearance="transparent" onClick={ () => resetCounter(0) } />
}
>
<Button
as="a" { ...extLink(buyMeACoffeeLink) }
onClick={ () => resetCounter(-1) }
appearance="primary"
className={ bmcCls.button }
icon={ <BuyMeACoffee20Regular /> }
>
{ i18n.t("common.cta.sponsor") }
</Button>
</MessageBarActions>
</MessageBar>
);
}
const ctaCounter = storage.defineItem<number>("local:ctaCounter", { fallback: 0 });
@@ -0,0 +1,21 @@
import useStorageInfo from "@/hooks/useStorageInfo";
import { MessageBar, MessageBarBody, MessageBarProps, MessageBarTitle } from "@fluentui/react-components";
export default function StorageCapacityIssueMessage(props: MessageBarProps): JSX.Element
{
const { usedStorageRatio } = useStorageInfo();
if (usedStorageRatio < 0.8)
return <></>;
return (
<MessageBar intent="warning" layout="multiline" { ...props }>
<MessageBarBody>
<MessageBarTitle>
{ i18n.t("storage_full_message.title", [(usedStorageRatio * 100).toFixed(1)]) }
</MessageBarTitle>
{ i18n.t("storage_full_message.message") }
</MessageBarBody>
</MessageBar>
);
}