Refactor UI components using React and TSX and providing new features (#113)

* Use react for settings popup

* Update options

* Update styling

* Remove unused type conversions

* Remove unused controls function

* Update project structure

* Delete unused code

* Legacy ui components

* Refactor files

* Update styling

* Update UI components

* Remove unused code

* Remove unused code

* Improve theming

* Update icon color

* Improve custom domains

* Update extension

* Add delete functionalty for custom domains

* Improve icon sizes on screen

* Implement icon bindings dialog

* Minor improvements

* Add tooltips

* Support lookup of language ids in manifest

* Implement watch mode for development purposes

* Improve language id binding customization

* Adjust node script

* Improve reset functionality

* Adjust node script

* Minor improvements

* Update binding controls with icons

* Organize imports

* Update error message

* Adjust icon binding dialog

* Add Info Popover

* Update autocomplete behavior

* Fix image issue

* Minor improvements

* Clean up code

* Make appbar sticky

* Improve project structure

* Update info text

* Adjust styling

* Update styling

* Improve adding new bindings

* Adjust tsconfig

* Support switch of themes for the icon preview

* Update watch script

* Improve error handling

* Move build languages step before build src
This commit is contained in:
Philipp Kief 2024-10-23 12:03:52 +02:00 committed by GitHub
parent 061bba8b60
commit 17c71b886e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 4501 additions and 1173 deletions

2934
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,13 @@
"url": "https://github.com/material-extensions/material-icons-browser-extension/issues"
},
"dependencies": {
"@emotion/react": "11.13.3",
"@emotion/styled": "11.13.0",
"@mui/icons-material": "6.1.2",
"@mui/material": "6.1.2",
"material-icon-theme": "5.11.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"selector-observer": "2.1.6",
"webextension-polyfill": "0.12.0"
},
@ -28,13 +34,17 @@
"@types/fs-extra": "11.0.4",
"@types/json-stable-stringify": "1.0.36",
"@types/node": "20.14.10",
"@types/react": "18.3.11",
"@types/react-dom": "18.3.0",
"@types/webextension-polyfill": "0.10.7",
"dotenv": "16.4.5",
"esbuild": "0.21.5",
"esbuild-sass-plugin": "3.3.1",
"fs-extra": "11.2.0",
"husky": "9.0.11",
"json-stable-stringify": "1.1.1",
"lint-staged": "15.2.7",
"nodemon": "3.1.7",
"npm-run-all": "4.1.5",
"rimraf": "5.0.7",
"sharp": "0.33.4",
@ -44,10 +54,11 @@
},
"scripts": {
"prebuild": "rimraf --glob *.zip ./dist",
"build": "run-s build-languages build-src compile bundle",
"build": "run-s build-languages build-src check-type-safety bundle",
"build-languages": "ts-node ./scripts/build-languages.ts",
"build-src": "ts-node ./scripts/build-src.ts",
"compile": "tsc -p ./",
"build-src-watch": "nodemon --watch ./src --ext ts,tsx,css,html --exec npm run build-src",
"check-type-safety": "tsc -p ./",
"rebuild-logos": "ts-node ./scripts/build-icons.ts",
"bundle": "run-p bundle-edge bundle-chrome bundle-firefox",
"bundle-edge": "zip -r -j github-material-icons-edge-extension.zip dist/chrome-edge",

View File

@ -45,6 +45,7 @@ function bundleJS(
minify: true,
sourcemap: false,
outdir: outDir,
loader: { '.svg': 'dataurl' },
};
return esbuild.build(buildOptions);
}
@ -61,21 +62,17 @@ function src(
const bundlePopupScript = (): Promise<esbuild.BuildResult> =>
bundleJS(
distPath,
path.resolve(srcPath, 'ui', 'popup', 'settings-popup.ts')
path.resolve(srcPath, 'ui', 'popup', 'settings-popup.tsx')
);
const bundleOptionsScript = (): Promise<esbuild.BuildResult> =>
bundleJS(distPath, path.resolve(srcPath, 'ui', 'options', 'options.ts'));
bundleJS(distPath, path.resolve(srcPath, 'ui', 'options', 'options.tsx'));
const bundleAll: Promise<esbuild.BuildResult> = bundleMainScript()
.then(bundlePopupScript)
.then(bundleOptionsScript);
const copyPopup: Promise<void[]> = Promise.all(
[
'settings-popup.html',
'settings-popup.css',
'settings-popup.github-logo.svg',
].map((file) =>
['settings-popup.html', 'settings-popup.css'].map((file) =>
fs.copy(
path.resolve(srcPath, 'ui', 'popup', file),
path.resolve(distPath, file)

View File

@ -17,3 +17,11 @@ export const addCustomProvider = (
return Browser.storage.sync.set({ customProviders });
});
export const removeCustomProvider = (name: string) => {
return getCustomProviders().then((customProviders) => {
delete customProviders[name];
Browser.storage.sync.set({ customProviders });
});
};

View File

@ -1,13 +1,13 @@
import { addConfigChangeListener, getConfig } from './user-config';
export type IconSize = 'sm' | 'md' | 'lg' | 'xl';
export const iconSizes = ['sm', 'md', 'lg', 'xl'];
export type IconSize = (typeof iconSizes)[number];
const setSizeAttribute = (iconSize: IconSize) =>
document.body.setAttribute(`data-material-icons-extension-size`, iconSize);
export const initIconSizes = () => {
const setIconSize = () =>
getConfig<IconSize>('iconSize').then(setSizeAttribute);
const setIconSize = () => getConfig('iconSize').then(setSizeAttribute);
document.addEventListener('DOMContentLoaded', setIconSize, false);

View File

@ -80,7 +80,7 @@ function replaceIcon(
}
// get correct icon name from icon list
iconName = iconsListTyped[iconName] ?? 'file.svg';
iconName = iconsListTyped[iconName] ?? (isDir ? 'folder.svg' : 'file.svg');
replaceElementWithIcon(iconEl, iconName, fileName, provider);
}
@ -122,14 +122,14 @@ function lookForMatch(
if (manifest.languageIds?.[ext]) return manifest.languageIds?.[ext];
}
if (languageMapTyped.fileNames[fileName])
return languageMapTyped.fileNames[fileName];
if (languageMapTyped.fileNames[lowerFileName])
return languageMapTyped.fileNames[lowerFileName];
for (const ext of fileExtensions) {
if (languageMapTyped.fileExtensions[ext])
return languageMapTyped.fileExtensions[ext];
}
const languageIcon = getLanguageIcon(
fileName,
lowerFileName,
fileExtensions
);
if (languageIcon)
return manifest.languageIds?.[languageIcon] ?? languageIcon;
return 'file';
}
@ -141,6 +141,23 @@ function lookForMatch(
return 'folder';
}
function getLanguageIcon(
fileName: string,
lowerFileName: string,
fileExtensions: string[]
): string | undefined {
if (languageMapTyped.fileNames[fileName])
return languageMapTyped.fileNames[fileName];
if (languageMapTyped.fileNames[lowerFileName])
return languageMapTyped.fileNames[lowerFileName];
for (const ext of fileExtensions) {
if (languageMapTyped.fileExtensions[ext])
return languageMapTyped.fileExtensions[ext];
}
return undefined;
}
function lookForLightMatch(
iconName: string,
fileName: string,

View File

@ -1,14 +1,26 @@
import { IconPackValue, generateManifest } from 'material-icon-theme';
import {
IconAssociations,
IconPackValue,
generateManifest,
} from 'material-icon-theme';
import { observe } from 'selector-observer';
import { Provider } from '../models';
import { replaceElementWithIcon, replaceIconInRow } from './replace-icon';
export const observePage = (
gitProvider: Provider,
iconPack: IconPackValue
iconPack: IconPackValue,
fileBindings?: IconAssociations,
folderBindings?: IconAssociations,
languageBindings?: IconAssociations
): void => {
const manifest = generateManifest({
activeIconPack: iconPack ?? undefined,
activeIconPack: iconPack || undefined,
files: { associations: fileBindings },
folders: { associations: folderBindings },
languages: {
associations: languageBindings,
},
});
observe(gitProvider.selectors.row, {

View File

@ -1,22 +1,32 @@
import { IconAssociations, IconPackValue } from 'material-icon-theme';
import Browser from 'webextension-polyfill';
import { IconSize } from './icon-sizes';
export type UserConfig = {
iconPack: string;
iconSize: string;
iconPack: IconPackValue;
iconSize: IconSize;
extEnabled: boolean;
fileIconBindings?: IconAssociations;
folderIconBindings?: IconAssociations;
languageIconBindings?: IconAssociations;
};
const hardDefaults: UserConfig = {
export const hardDefaults: UserConfig = {
iconPack: 'react',
iconSize: 'md',
extEnabled: true,
fileIconBindings: {},
folderIconBindings: {},
languageIconBindings: {},
};
export const getConfig = async <T = unknown>(
configName: keyof UserConfig,
type ConfigValueType<T extends keyof UserConfig> = UserConfig[T];
export const getConfig = async <T extends keyof UserConfig>(
configName: T,
domain = window.location.hostname,
useDefault = true
): Promise<T> => {
): Promise<ConfigValueType<T>> => {
const keys = {
[`${domain !== 'default' ? domain : 'SKIP'}:${configName}`]: null,
[`default:${configName}`]: hardDefaults[configName],
@ -29,14 +39,15 @@ export const getConfig = async <T = unknown>(
return domainSpecificValue ?? (useDefault ? defaultValue : null);
};
export const setConfig = (
configName: keyof UserConfig,
value: unknown,
export const setConfig = <T extends keyof UserConfig>(
configName: T,
value: ConfigValueType<T>,
domain = window.location.hostname
) =>
) => {
Browser.storage.sync.set({
[`${domain}:${configName}`]: value,
});
};
export const clearConfig = (
configName: keyof UserConfig,

View File

@ -1,6 +0,0 @@
/**
* A helper function to check if an object has a key value, without including prooerties from the prototype chain.
*/
export function objectHas(obj: object, key: string) {
return obj.hasOwnProperty(key);
}

View File

@ -20,13 +20,22 @@ const handleProvider = async (href: string) => {
const provider: Provider | null = await getGitProvider(href);
if (!provider) return;
const iconPack = await getConfig<IconPackValue>('iconPack');
const extEnabled = await getConfig<boolean>('extEnabled');
const globalExtEnabled = await getConfig<boolean>('extEnabled', 'default');
const iconPack = await getConfig('iconPack');
const fileBindings = await getConfig('fileIconBindings');
const folderBindings = await getConfig('folderIconBindings');
const languageBindings = await getConfig('languageIconBindings');
const extEnabled = await getConfig('extEnabled');
const globalExtEnabled = await getConfig('extEnabled', 'default');
if (!globalExtEnabled || !extEnabled) return;
observePage(provider, iconPack);
observePage(
provider,
iconPack,
fileBindings,
folderBindings,
languageBindings
);
addConfigChangeListener('iconPack', () => replaceAllIcons(provider));
};
@ -49,7 +58,7 @@ const handlers: Handlers = {
Browser.runtime.onMessage.addListener(
(
message: { cmd: keyof Handlers; args?: any[] },
message: { cmd: keyof Handlers; args?: unknown[] },
_: Browser.Runtime.MessageSender,
sendResponse: (response?: any) => void
) => {

View File

@ -1,4 +1,4 @@
export interface Provider {
export type Provider = {
name: string;
domains: { host: string; test: RegExp }[];
selectors: {
@ -20,4 +20,10 @@ export interface Provider {
iconEl: HTMLElement,
fileName: string
) => string;
}
};
export type Domain = Pick<Provider, 'name' | 'isCustom'> & {
isDefault: boolean;
};
export type ProviderMap = Record<string, string>;

View File

@ -26,7 +26,7 @@ export default function gitlab(): Provider {
.tree-item svg, .file-header-content svg:not(.gl-button-icon),
.gl-link svg.gl-icon[data-testid="doc-code-icon"]`,
// Element by which to detect if the tested domain is gitlab.
detect: 'body.page-initialized[data-page]',
detect: 'head meta[content="GitLab"]',
},
canSelfHost: true,
isCustom: false,

View File

@ -52,6 +52,10 @@ export const addGitProvider = (
providerConfig[name] = provider;
};
export const removeGitProvider = (name: string) => {
delete providerConfig[name];
};
export const getGitProviders = () =>
getCustomProviders().then((customProviders) => {
for (const [domain, handler] of Object.entries(customProviders)) {

View File

@ -0,0 +1,15 @@
import { Domain } from '@/models';
import { getGitProviders } from '@/providers';
export function getDomains(): Promise<Domain[]> {
return getGitProviders().then((providers) => [
{ name: 'default', isCustom: false, isDefault: true },
...Object.values(providers).flatMap((p) =>
p.domains.map((d) => ({
name: d.host,
isCustom: p.isCustom,
isDefault: false,
}))
),
]);
}

View File

@ -0,0 +1,39 @@
import iconsList from '../../../icon-list.json';
const iconsListTyped = iconsList as Record<string, string>;
const blacklist = ['_light', '_highContrast'];
function isNotBlacklisted(name: string): boolean {
return !blacklist.some((term) => name.includes(term));
}
function filterIcons(predicate: (name: string) => boolean): string[] {
return Object.keys(iconsListTyped).filter(predicate).sort();
}
export function getIconFileName(
iconName: string,
isLightMode: boolean
): string {
const lightIconName = `${iconName}_light`;
if (isLightMode && iconsListTyped[lightIconName]) {
return iconsListTyped[lightIconName];
}
return iconsListTyped[iconName];
}
export function getListOfFileIcons(): string[] {
return filterIcons(
(name) => !name.startsWith('folder') && isNotBlacklisted(name)
);
}
export function getListOfFolderIcons(): string[] {
return filterIcons(
(name) =>
name.startsWith('folder') &&
!name.includes('-open') &&
!name.includes('-root') &&
isNotBlacklisted(name)
).map((name) => name.replace('folder-', ''));
}

View File

@ -0,0 +1,22 @@
import languageMap from '../../../language-map.json';
const languageMapTyped = languageMap as {
fileExtensions: Record<string, string>;
fileNames: Record<string, string>;
};
/**
* Get list of all supported language ids.
*
* @returns a list of language ids
*/
export function getLanguageIds(): string[] {
return Object.values(languageMapTyped.fileExtensions)
.concat(Object.values(languageMapTyped.fileNames))
.reduce((acc, curr) => {
if (!acc.includes(curr)) {
acc.push(curr);
}
return acc;
}, [] as string[])
.sort();
}

View File

@ -0,0 +1,37 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from '@mui/material';
type ConfirmDialogProps = {
title: string;
message: string;
show: boolean;
onConfirm: () => void;
onCancel: () => void;
};
export function ConfirmDialog({
title,
message,
show,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
return (
<Dialog open={show} onClose={onCancel}>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<DialogContentText>{message}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onCancel}>Cancel</Button>
<Button onClick={onConfirm}>Confirm</Button>
</DialogActions>
</Dialog>
);
}

View File

@ -0,0 +1,61 @@
import { Domain } from '@/models';
import DeleteIcon from '@mui/icons-material/Delete';
import SettingsIcon from '@mui/icons-material/Settings';
import { IconButton, Tooltip } from '@mui/material';
import { useState } from 'react';
import { ConfirmDialog } from './confirm-dialog';
import { IconSettingsDialog } from './icon-settings/icon-settings-dialog';
export function DomainActions({
domain,
deleteDomain,
}: { domain: Domain; deleteDomain?: () => void }) {
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [showSettingsDialog, setShowSettingsDialog] = useState(false);
return (
<div>
<Tooltip title='Configure icon bindings'>
<IconButton
onClick={() => {
setShowSettingsDialog(true);
}}
>
<SettingsIcon />
</IconButton>
</Tooltip>
{domain.isCustom ? (
<Tooltip title='Delete custom domain'>
<IconButton
onClick={() => {
setShowConfirmDialog(true);
}}
>
<DeleteIcon />
</IconButton>
</Tooltip>
) : null}
<ConfirmDialog
title='Delete domain'
message={`Are you sure to delete the domain ${domain.name}?`}
onConfirm={() => {
deleteDomain?.();
setShowConfirmDialog(false);
}}
onCancel={() => {
setShowConfirmDialog(false);
}}
show={showConfirmDialog}
/>
<IconSettingsDialog
domain={domain}
onClose={() => {
setShowSettingsDialog(false);
}}
show={showSettingsDialog}
/>
</div>
);
}

View File

@ -0,0 +1,22 @@
import { Domain } from '@/models';
import PublicIcon from '@mui/icons-material/Public';
import { Typography } from '@mui/material';
import { CSSProperties } from 'react';
export function DomainName({ domain }: { domain: Domain }) {
const styles: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: '.5rem',
fontWeight: 600,
};
return (
<Typography color='textPrimary'>
<div style={styles}>
<PublicIcon />
<span>{domain.name}</span>
</div>
</Typography>
);
}

View File

@ -0,0 +1,113 @@
import { IconSize } from '@/lib/icon-sizes';
import {
clearConfig,
getConfig,
hardDefaults,
setConfig,
} from '@/lib/user-config';
import { Domain } from '@/models';
import { IconPackValue } from 'material-icon-theme';
import { CSSProperties, useEffect, useState } from 'react';
import { DomainSettingsControls } from '../../shared/domain-settings-controls';
import { DomainActions } from './domain-actions';
import { DomainName } from './domain-name';
export function DomainSettings({
domain,
deleteDomain,
}: { domain: Domain; deleteDomain?: () => void }) {
const [iconSize, setIconSize] = useState<IconSize | undefined>(
hardDefaults.iconSize
);
const [iconPack, setIconPack] = useState<IconPackValue | undefined>(
hardDefaults.iconPack
);
const [extensionEnabled, setExtensionEnabled] = useState<boolean>(
hardDefaults.extEnabled
);
useEffect(() => {
getConfig('iconSize', domain.name, false).then(setIconSize);
getConfig('iconPack', domain.name, false).then(setIconPack);
getConfig('extEnabled', domain.name, false).then(setExtensionEnabled);
const handleResetAllDomains = (event: Event) => {
if (event.type === 'RESET_ALL_DOMAINS') {
resetToDefaults();
}
};
if (domain.name !== 'default') {
window.addEventListener('RESET_ALL_DOMAINS', handleResetAllDomains);
// return cleanup function
return () => {
window.removeEventListener('RESET_ALL_DOMAINS', handleResetAllDomains);
};
}
}, []);
const changeIconSize = (iconSize: IconSize) => {
setConfig('iconSize', iconSize, domain.name);
setIconSize(iconSize);
};
const changeIconPack = (iconPack: IconPackValue) => {
setConfig('iconPack', iconPack, domain.name);
setIconPack(iconPack);
};
const changeVisibility = (visible: boolean) => {
setConfig('extEnabled', visible, domain.name);
setExtensionEnabled(visible);
};
const resetToDefaults = async () => {
await clearConfig('iconSize', domain.name);
await clearConfig('iconPack', domain.name);
await clearConfig('extEnabled', domain.name);
await clearConfig('languageIconBindings', domain.name);
await clearConfig('fileIconBindings', domain.name);
await clearConfig('folderIconBindings', domain.name);
setIconSize(undefined);
setIconPack(undefined);
setExtensionEnabled(hardDefaults.extEnabled);
};
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWindowWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const breakpointWidth = 1024;
const styles: CSSProperties = {
display: 'grid',
gridTemplateColumns:
windowWidth <= breakpointWidth ? '1fr 1fr' : '2fr 1fr 1fr 1fr .5fr',
color: 'text.primary',
alignItems: 'center',
fontSize: '1rem',
padding: windowWidth <= breakpointWidth ? '0' : '0.5rem 1.5rem',
gap: windowWidth <= breakpointWidth ? '0.5rem' : '1.5rem',
};
return (
<div style={styles}>
<DomainName domain={domain} />
<DomainSettingsControls
iconSize={iconSize}
iconPack={iconPack}
extensionEnabled={extensionEnabled}
changeVisibility={changeVisibility}
changeIconSize={changeIconSize}
changeIconPack={changeIconPack}
/>
<DomainActions domain={domain} deleteDomain={deleteDomain} />
</div>
);
}

View File

@ -0,0 +1,45 @@
import { Autocomplete, TextField } from '@mui/material';
import { WithBindingProps } from '../../types/binding-control-props';
type BindingControlsProps = {
binding: string;
index: number;
placeholder: string;
label: string;
changeBinding: (index: number, value: string) => void;
};
export function BindingControls({
binding,
index,
bindings,
bindingsLabel,
placeholder,
label,
changeBinding,
}: WithBindingProps<BindingControlsProps>): JSX.Element {
return bindings ? (
<Autocomplete
value={binding}
onChange={(_, value) => {
if (value !== null) {
changeBinding(index, value);
}
}}
options={bindings}
sx={{ width: '100%' }}
renderInput={(params) => <TextField {...params} label={bindingsLabel} />}
/>
) : (
<TextField
label={label}
variant='outlined'
value={binding}
fullWidth
onChange={(e) => {
changeBinding(index, e.target.value);
}}
placeholder={placeholder}
/>
);
}

View File

@ -0,0 +1,17 @@
import { Domain } from '@/models';
import { getListOfFileIcons } from '../../api/icons';
import { IconBindingControls } from './icon-binding-controls';
export function FileIconBindings({ domain }: { domain: Domain }) {
return (
<IconBindingControls
title='File Icon Bindings'
domain={domain}
iconInfoText='Enter a file extension (e.g., *.ts) or a file name (e.g., tsconfig.json).'
iconList={getListOfFileIcons()}
configName='fileIconBindings'
placeholder='*.ts / tsconfig.json'
label='File name or extension'
/>
);
}

View File

@ -0,0 +1,17 @@
import { Domain } from '@/models';
import { getListOfFolderIcons } from '../../api/icons';
import { IconBindingControls } from './icon-binding-controls';
export function FolderIconBindings({ domain }: { domain: Domain }) {
return (
<IconBindingControls
title='Folder Icon Bindings'
domain={domain}
iconInfoText='Enter the exact folder name, e.g. src.'
iconList={getListOfFolderIcons()}
configName='folderIconBindings'
placeholder='src / dist'
label='Folder name'
/>
);
}

View File

@ -0,0 +1,202 @@
import { UserConfig, getConfig, setConfig } from '@/lib/user-config';
import { Domain } from '@/models';
import { InfoPopover } from '@/ui/shared/info-popover';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import {
Autocomplete,
Box,
Button,
IconButton,
InputAdornment,
TextField,
Tooltip,
} from '@mui/material';
import { IconAssociations } from 'material-icon-theme';
import { CSSProperties, useEffect, useState } from 'react';
import { WithBindingProps } from '../../types/binding-control-props';
import { BindingControls } from './binding-input-controls';
import { IconPreview } from './icon-preview';
type IconBindingControlProps = {
title: string;
domain: Domain;
iconList: string[];
configName: keyof Pick<
UserConfig,
'fileIconBindings' | 'folderIconBindings' | 'languageIconBindings'
>;
placeholder: string;
label: string;
iconInfoText: string;
bindings?: string[];
bindingsLabel?: string;
};
export function IconBindingControls({
title,
domain,
iconList,
configName,
placeholder,
label,
iconInfoText,
bindings,
bindingsLabel,
}: WithBindingProps<IconBindingControlProps>) {
type IconBinding = {
binding: string;
iconName: string | null;
};
const [iconBindings, setIconBindings] = useState<IconBinding[]>([
{ binding: '', iconName: null },
]);
useEffect(() => {
getConfig(configName, domain.name, false).then((iconBinding) => {
const bindings = Object.entries(iconBinding ?? []).map(
([binding, iconName]) => ({
binding,
iconName,
})
);
setIconBindings(bindings);
});
}, []);
const iconBindingStyle: CSSProperties = {
width: '100%',
display: 'grid',
gridTemplateColumns: '1fr 1fr 2rem',
alignItems: 'center',
gap: '1rem',
marginBottom: '1rem',
};
const controlStyling: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
};
const transformIconBindings = (bindings: IconBinding[]): IconAssociations => {
return bindings.reduce((acc, { binding: fileBinding, iconName }) => {
if (iconName === null) {
return acc;
}
return {
...acc,
[fileBinding]: iconName,
};
}, {});
};
const updateConfig = (bindings: IconBinding[]) => {
setIconBindings(bindings);
setConfig(configName, transformIconBindings(bindings), domain.name);
};
const changeBinding = (index: number, value: string) => {
const newIconBindings = [...iconBindings];
newIconBindings[index].binding = value;
updateConfig(newIconBindings);
};
const onChangeIconName = (index: number, value: string | null) => {
const newIconBindings = [...iconBindings];
newIconBindings[index].iconName = value;
updateConfig(newIconBindings);
};
const addIconBinding = () => {
setIconBindings([...iconBindings, { binding: '', iconName: null }]);
};
const removeBinding = (index: number) => {
const newIconBindings = [...iconBindings];
newIconBindings.splice(index, 1);
setIconBindings(newIconBindings);
updateConfig(newIconBindings);
};
return (
<div>
<h3>{title}</h3>
<div style={controlStyling}>
{iconBindings.map(({ binding, iconName }, index) => (
<div key={index} style={iconBindingStyle}>
<InfoPopover
renderContent={() => (
<BindingControls
binding={binding}
index={index}
placeholder={placeholder}
label={label}
changeBinding={changeBinding}
bindings={bindings}
bindingsLabel={bindingsLabel}
/>
)}
infoText={iconInfoText}
/>
<Autocomplete
value={iconName}
onChange={(_, value) => {
onChangeIconName(index, value);
}}
renderOption={(props, option) => {
const { key, ...optionProps } = props;
return (
<Box
key={key}
component='li'
sx={{ '& > img': { mr: 2, flexShrink: 0 } }}
{...optionProps}
>
<IconPreview configName={configName} iconName={option} />
{option}
</Box>
);
}}
options={iconList}
sx={{ width: '100%' }}
renderInput={(params) => (
<TextField
{...params}
slotProps={{
input: {
...params.InputProps,
startAdornment: (
<InputAdornment
position='start'
style={{ paddingLeft: '0.5rem' }}
>
<IconPreview
configName={configName}
iconName={iconName ?? undefined}
/>
</InputAdornment>
),
},
}}
label='Icon'
/>
)}
/>
<div>
<Tooltip title='Remove binding'>
<IconButton onClick={() => removeBinding(index)}>
<DeleteIcon />
</IconButton>
</Tooltip>
</div>
</div>
))}
</div>
<Button onClick={addIconBinding} startIcon={<AddIcon />}>
Add new binding
</Button>
</div>
);
}

View File

@ -0,0 +1,35 @@
import { useTheme } from '@mui/material/styles';
import { getIconFileName } from '../../api/icons';
interface IconPreviewProps {
configName: string;
iconName?: string;
}
export function IconPreview({ configName, iconName }: IconPreviewProps) {
const theme = useTheme();
const getIconSrc = (iconName?: string): string | undefined => {
if (configName === 'folderIconBindings') {
return iconName === 'folder' ? 'folder' : `folder-${iconName}`;
}
return iconName;
};
if (!iconName) {
return null;
}
const iconSrc = getIconSrc(iconName)?.toLowerCase();
if (!iconSrc) {
return null;
}
return (
<img
loading='lazy'
width='20'
src={`./${getIconFileName(iconSrc, theme.palette.mode === 'light')}`}
alt={`${iconName} icon`}
/>
);
}

View File

@ -0,0 +1,42 @@
import { Domain } from '@/models';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Typography,
} from '@mui/material';
import { FileIconBindings } from './file-icon-bindings';
import { FolderIconBindings } from './folder-icon-bindings';
import { LanguageIconBindings } from './language-icon-bindings';
type IconSettingsDialogProps = {
show: boolean;
domain: Domain;
onClose: () => void;
};
export function IconSettingsDialog({
show,
domain,
onClose,
}: IconSettingsDialogProps) {
return (
<Dialog open={show} onClose={onClose} fullWidth={true} maxWidth='lg'>
<DialogTitle>Configure Icon Bindings</DialogTitle>
<DialogContent>
<Typography component='div' style={{ paddingBottom: '1.5rem' }}>
Domain: {domain.name}
</Typography>
<FileIconBindings domain={domain} />
<FolderIconBindings domain={domain} />
<LanguageIconBindings domain={domain} />
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
}

View File

@ -0,0 +1,20 @@
import { Domain } from '@/models';
import { getListOfFileIcons } from '../../api/icons';
import { getLanguageIds } from '../../api/language-ids';
import { IconBindingControls } from './icon-binding-controls';
export function LanguageIconBindings({ domain }: { domain: Domain }) {
return (
<IconBindingControls
title='Language Icon Bindings'
domain={domain}
iconInfoText='Select a supported language ID from the dropdown list.'
iconList={getListOfFileIcons()}
bindings={getLanguageIds()}
bindingsLabel='Language ID'
configName='languageIconBindings'
placeholder='typescript / javascript'
label='Language ID'
/>
);
}

View File

@ -0,0 +1,138 @@
import { removeCustomProvider } from '@/lib/custom-providers';
import { Domain } from '@/models';
import { removeGitProvider } from '@/providers';
import { InfoPopover } from '@/ui/shared/info-popover';
import { Logo } from '@/ui/shared/logo';
import { theme } from '@/ui/shared/theme';
import {
Alert,
AppBar,
Button,
CssBaseline,
Toolbar,
Typography,
} from '@mui/material';
import Box from '@mui/material/Box';
import { ThemeProvider } from '@mui/material/styles';
import { useEffect, useState } from 'react';
import { Footer } from '../../shared/footer';
import { getDomains } from '../api/domains';
import { ConfirmDialog } from './confirm-dialog';
import { DomainSettings } from './domain-settings';
function Options() {
const [customDomains, setCustomDomains] = useState<Domain[]>([]);
const [defaultDomain, setDefaultDomain] = useState<Domain>();
const [initialDomains, setInitialDomains] = useState<Domain[]>([]);
const [showResetConfirmDialog, setShowResetConfirmDialog] = useState(false);
useEffect(() => {
updateDomains();
}, []);
const resetAll = async () => {
const event = new CustomEvent('RESET_ALL_DOMAINS');
window.dispatchEvent(event);
};
const updateDomains = () => {
return getDomains().then((domains) => {
const customDomainsList = domains.filter((domain) => domain.isCustom);
const initialDomainList = domains.filter(
(domain) => !domain.isCustom && !domain.isDefault
);
const defaultDomain = domains.find((domain) => domain.isDefault);
setCustomDomains(customDomainsList);
setInitialDomains(initialDomainList);
setDefaultDomain(defaultDomain);
});
};
const deleteDomain = async (domain: Domain) => {
await removeCustomProvider(domain.name);
await removeGitProvider(domain.name);
await updateDomains();
};
const containerStyling = {
width: '100%',
bgcolor: 'background.default',
borderRadius: 0,
color: 'text.primary',
};
return (
<>
<Box sx={containerStyling}>
<AppBar position='sticky'>
<Toolbar>
<Logo />
<Typography
variant='h6'
component='div'
style={{ paddingLeft: '.5rem' }}
>
Material Icons
</Typography>
<span className='toolbar-spacer'></span>
<Button
onClick={() => setShowResetConfirmDialog(true)}
sx={{ color: 'white' }}
>
Reset all
</Button>
</Toolbar>
</AppBar>
<Box p={4}>
<div style={{ width: 'fit-content' }}>
<InfoPopover
infoText='The settings of the default domain will be applied to all other domains unless they are overridden.'
renderContent={() => <h3>Default domain</h3>}
/>
</div>
{defaultDomain && <DomainSettings domain={defaultDomain} />}
<h3>Other domains</h3>
{initialDomains.map((domain) => (
<DomainSettings domain={domain} />
))}
{customDomains.length > 0 && <h3>Custom domains</h3>}
{customDomains.map((domain) => (
<DomainSettings
domain={domain}
deleteDomain={() => deleteDomain(domain)}
/>
))}
</Box>
<Footer />
</Box>
{/* Dialogs */}
<ConfirmDialog
title='Reset all domains'
message={`Are you sure to reset all domain settings to the settings of the default domain? It will also put the icon bindings to the default domain.`}
onConfirm={() => {
resetAll();
setShowResetConfirmDialog(false);
}}
onCancel={() => {
setShowResetConfirmDialog(false);
}}
show={showResetConfirmDialog}
/>
</>
);
}
export default function Main() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Options />
</ThemeProvider>
);
}

View File

@ -1,144 +1,14 @@
body {
padding: 0;
margin: 0;
color: #1a202c;
font-size: 16px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
background-color: aliceblue;
}
.centered {
display: flex;
align-items: center;
justify-content: center;
}
#settings-header {
margin-top: 32px;
gap: 20px;
}
#logo {
height: 2.4rem;
width: 2.4rem;
}
#domains {
display: flex;
flex-direction: column;
width: 80%;
align-items: center;
padding: 0 1rem;
* {
box-sizing: border-box;
border-radius: 10px;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
margin: 2rem;
max-width: 800px;
}
#domains-header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
.toolbar-spacer {
flex: 1 1 auto;
}
#reset {
color: #5c6068;
padding: 0.3rem 1rem;
outline: transparent solid 2px;
outline-offset: 2px;
transition: 200ms all;
font-size: 1rem;
border-radius: 0.375rem;
border-width: 1px;
border-color: transparent;
background-color: transparent;
}
#reset:hover {
border-color: red;
color: red;
cursor: pointer;
}
.domain {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
width: 100%;
padding: 0 1rem;
border-top: 1px solid #cbd5e0;
}
.domain.disabled {
background-color: rgb(177, 177, 177);
color: rgb(78, 78, 78);
}
.domain:hover {
background-color: rgb(232, 239, 250);
}
.domain.disabled,
.domain.disabled:hover {
background-color: rgb(177, 177, 177);
color: rgb(78, 78, 78);
}
.domain h3 {
margin-right: auto;
}
.domain:last-child {
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
}
.animated {
transition: all 0.8s ease;
}
.brightDomain {
color: red;
}
#row-default {
background-color: #cad9f7;
}
label {
color: #5c6068;
font-size: 0.9rem;
}
select {
padding: 0.3rem 1rem;
outline: transparent solid 2px;
outline-offset: 2px;
appearance: none;
transition: 200ms all;
font-size: 1rem;
border-radius: 0.375rem;
border-width: 1px;
color: #2d3748;
border-color: transparent;
background-color: transparent;
min-width: 100px;
text-align: start;
}
select:hover {
border-color: #cbd5e0;
background-color: aliceblue;
}
select:focus-visible {
border-color: #3182ce;
box-shadow: #3182ce 0px 0px 0px 1px;
}

View File

@ -1,64 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<link rel="stylesheet" href="options.css" />
</head>
<body>
<div id="settings-header" class="centered">
<img id="logo" src="/icon-128.png" />
<h1>Settings</h1>
</div>
<div id="domains">
<div id="domains-header">
<h2>Domains</h2>
<button id="reset">reset all</button>
</div>
<template id="domain-row">
<div class="domain">
<input type="checkbox" class="extEnabled" />
<h3 class="domain-name"></h3>
<label class="domain-icon-size-label">Icon Size:</label>
<div class="select-wrapper">
<select class="iconSize">
<option selected class="default-option" value="'default'"></option>
<option value="sm">Small</option>
<option value="md">Medium</option>
<option value="lg">Large</option>
<option value="xl">Extra Large</option>
</select>
</div>
<label class="domain-icon-pack-label">Icon Pack:</label>
<div class="select-wrapper">
<select class="iconPack">
<option selected class="default-option" value="'default'"></option>
<option value="angular">Angular</option>
<option value="angular_ngrx">Angular + Ngrx</option>
<option value="react">React</option>
<option value="react_redux">React + Redux</option>
<option value="qwik">Qwik</option>
<option value="vue">Vue</option>
<option value="vue_vuex">Vue + Vuex</option>
<option value="nest">Nest</option>
<option value="none">None</option>
</select>
</div>
</div>
</template>
</div>
<div id="footer" class="centered">
<a href="https://github.com/material-extensions/material-icons-browser-extension" target="_blank"><img
src="settings-popup.github-logo.svg" /></a>
</div>
<script src="options.js"></script>
</body>
<html lang="en">
<head>
<title>Material Icons</title>
<script type="module" crossorigin src="options.js"></script>
<link rel="stylesheet" href="options.css" />
</head>
<body>
<div id="options"></div>
</body>
</html>

View File

@ -1,149 +0,0 @@
import {
UserConfig,
addConfigChangeListener,
clearConfig,
getConfig,
setConfig,
} from '../../lib/user-config';
import { getGitProviders } from '../../providers';
const resetButton = document.getElementById(
'reset'
) as HTMLButtonElement | null;
interface DomainRowElement extends HTMLElement {
id: string;
}
const newDomainRow = (): ChildNode => {
const template = document.getElementById('domain-row');
if (template instanceof HTMLTemplateElement) {
const row = template.content.firstElementChild?.cloneNode(true);
if (!row) throw new Error('Row clone failed');
return row as ChildNode;
}
throw new Error('No row template found');
};
const domainToggles = (row: DomainRowElement): void => {
if (row.id === 'row-default') return;
const toggleRow = (allEnabled: boolean): void => {
const checkbox = row.querySelector(
'.extEnabled'
) as HTMLInputElement | null;
if (checkbox) {
checkbox.disabled = !allEnabled;
checkbox.indeterminate = !allEnabled;
}
if (allEnabled) row.classList.remove('disabled');
else row.classList.add('disabled');
};
getConfig<boolean>('extEnabled', 'default').then(toggleRow);
addConfigChangeListener('extEnabled', toggleRow, 'default');
};
const fillRow = (
rowElement: ChildNode,
domain: string
): Promise<DomainRowElement> => {
const row = rowElement as DomainRowElement;
row.id = `row-${domain}`;
const title = row.querySelector('.domain-name');
if (title) title.appendChild(document.createTextNode(domain));
if (domain === 'default') {
row.querySelectorAll('.default-option').forEach((opt) => opt.remove());
}
resetButton?.addEventListener('click', () => {
row.classList.add('brightDomain');
setTimeout(() => row.classList.add('animated'), 0);
setTimeout(() => row.classList.remove('brightDomain'), 0);
setTimeout(() => row.classList.remove('animated'), 800);
});
const wireConfig = <T>(
configName: keyof UserConfig,
updateInput: (input: HTMLElement) => (val: T) => void,
updateConfig: (configName: keyof UserConfig) => (event: Event) => void
): Promise<void> => {
const input = row.querySelector(`.${configName}`) as HTMLElement;
const populateInput = (): Promise<void> =>
getConfig<T>(configName, domain, false).then(updateInput(input));
input.addEventListener('change', updateConfig(configName));
addConfigChangeListener(configName, updateInput(input), domain);
addConfigChangeListener(
configName,
() => getConfig<T>(configName, domain, false).then(updateInput(input)),
'default'
);
resetButton?.addEventListener('click', () =>
clearConfig(configName, domain).then(populateInput)
);
input.querySelectorAll('.default-option').forEach((opt) => {
input.addEventListener('focus', () => {
(opt as HTMLOptionElement).text = '(default)';
});
input.addEventListener('blur', () => {
(opt as HTMLOptionElement).text = '';
});
});
return populateInput();
};
const updateSelect = (input: HTMLElement) => (val?: string) => {
(input as HTMLSelectElement).value = val ?? 'default';
};
const updateConfigFromSelect =
(configName: keyof UserConfig) =>
({ target }: Event) => {
const value = (target as HTMLSelectElement).value;
return !value || value === '(default)'
? clearConfig(configName, domain)
: setConfig(configName, value, domain);
};
const wireSelect = (configName: keyof UserConfig) =>
wireConfig(configName, updateSelect, updateConfigFromSelect);
const updateCheck = (input: HTMLElement) => (val?: boolean) => {
(input as HTMLInputElement).checked = val ?? true;
};
const updateConfigFromCheck =
(configName: keyof UserConfig) =>
({ target }: Event) => {
const checked = (target as HTMLInputElement).checked;
return setConfig(configName, checked, domain);
};
const wireCheck = (configName: keyof UserConfig) =>
wireConfig(configName, updateCheck, updateConfigFromCheck);
return Promise.all([
wireSelect('iconSize'),
wireSelect('iconPack'),
wireCheck('extEnabled'),
])
.then(() => domainToggles(row))
.then(() => row);
};
function getDomains(): Promise<string[]> {
return getGitProviders().then((providers) => [
'default',
...Object.values(providers).flatMap((p) => p.domains.map((d) => d.host)),
]);
}
const domainsDiv = document.getElementById('domains') as HTMLDivElement;
getDomains().then((domains) => {
Promise.all(domains.map((d) => fillRow(newDomainRow(), d))).then((rows) =>
rows.forEach((r) => domainsDiv.appendChild(r))
);
});

View File

@ -0,0 +1,12 @@
import { StyledEngineProvider } from '@mui/material/styles';
import * as React from 'react';
import * as ReactDOM from 'react-dom/client';
import Options from './components/main';
ReactDOM.createRoot(document.getElementById('options') as HTMLElement).render(
<React.StrictMode>
<StyledEngineProvider injectFirst>
<Options />
</StyledEngineProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,9 @@
/**
* If binding values are provided, the component will render a dropdown for selecting the binding values.
* Therefor, the `bindings` and `bindingsLabel` props are required.
*/
export type WithBindingProps<T> = T &
(
| { bindings: string[] | undefined; bindingsLabel: string | undefined }
| { bindings?: undefined; bindingsLabel?: undefined }
);

View File

@ -0,0 +1,88 @@
import Browser from 'webextension-polyfill';
export function checkAccess(tab: Browser.Tabs.Tab) {
const { host } = new URL(tab.url ?? '');
const perm = {
permissions: ['activeTab'],
origins: [`*://${host}/*`],
};
return Browser.permissions.contains(perm).then(async (r) => {
if (r) {
await ensureContentScriptRegistered(tab);
return tab;
}
return false;
});
}
export function requestAccess(tab: Browser.Tabs.Tab) {
const { host } = new URL(tab.url ?? '');
const perm: Browser.Permissions.Permissions = {
permissions: ['activeTab'],
origins: [`*://${host}/*`],
};
// request the permission
Browser.permissions.request(perm).then(async (granted: boolean) => {
if (!granted) {
return;
}
// when granted reload the popup to show ui changes
window.location.reload();
});
// close the popup, in firefox it stays open for some reason.
window.close();
}
async function ensureContentScriptRegistered(tab: Browser.Tabs.Tab) {
const { host } = new URL(tab.url ?? '');
const scripts = await Browser.scripting.getRegisteredContentScripts({
ids: ['material-icons'],
});
const pattern: string = `*://${host}/*`;
if (!scripts.length) {
// run the script now in the current tab to prevent need for reloading
await Browser.scripting.executeScript({
files: ['./main.js'],
target: {
tabId: tab.id ?? 0,
},
});
// register content script for future use
return Browser.scripting.registerContentScripts([
{
id: 'material-icons',
js: ['./main.js'],
css: ['./injected-styles.css'],
matches: [pattern],
runAt: 'document_start',
},
]);
}
const matches = scripts[0].matches ?? [];
// if we somehow already registered the script for requested origin, skip it
if (matches.includes(pattern)) {
return;
}
// add new origin to content script
return Browser.scripting.updateContentScripts([
{
id: 'material-icons',
matches: [...matches, pattern],
},
]);
}

View File

@ -0,0 +1,28 @@
import { getGitProvider } from '@/providers';
import Browser from 'webextension-polyfill';
export function getElementByIdOrThrow<T = HTMLElement>(
id: string
): NonNullable<T> {
const el = document.getElementById(id) as T | null;
if (!el) {
throw new Error(`Element with id ${id} not found`);
}
return el;
}
export const isPageSupported = (domain: string) => getGitProvider(domain);
export function getCurrentTab() {
const queryOptions = { active: true, currentWindow: true };
return Browser.tabs.query(queryOptions).then(([tab]) => tab);
}
export function getDomainFromCurrentTab() {
return getCurrentTab().then((tab) => {
const url = new URL(tab.url ?? '');
return url.hostname;
});
}

View File

@ -0,0 +1,28 @@
import Browser from 'webextension-polyfill';
import { checkAccess } from './access';
import { isPageSupported } from './helper';
export enum PageState {
Supported,
NotSupported,
AskForAccess,
HasAccess,
}
export const checkPageState = async (tab: Browser.Tabs.Tab) => {
const domain = new URL(tab.url ?? '').host;
const supported = await isPageSupported(domain);
if (supported) return PageState.Supported;
// we are in some internal browser page, not supported.
if (tab.url && !tab.url.startsWith('http')) {
return PageState.NotSupported;
}
const access = await checkAccess(tab);
if (access === false) {
return PageState.AskForAccess;
} else {
return PageState.HasAccess;
}
};

View File

@ -0,0 +1,24 @@
import { ProviderMap } from '@/models';
import { providerConfig } from '@/providers';
import Browser from 'webextension-polyfill';
export async function guessProvider(tab: Browser.Tabs.Tab) {
const possibilities: ProviderMap = {};
for (const provider of Object.values(providerConfig)) {
if (
!provider.isCustom &&
provider.canSelfHost &&
provider.selectors.detect
) {
possibilities[provider.name] = provider.selectors.detect;
}
}
const cmd = {
cmd: 'guessProvider',
args: [possibilities],
};
return (await Browser.tabs.sendMessage(tab.id ?? 0, cmd)) ?? false;
}

View File

@ -0,0 +1,79 @@
import { addCustomProvider } from '@/lib/custom-providers';
import { addGitProvider, providerConfig } from '@/providers';
import {
Box,
Button,
FormControl,
InputLabel,
MenuItem,
Select,
Typography,
} from '@mui/material';
import { useEffect, useState } from 'react';
import Browser from 'webextension-polyfill';
import { getCurrentTab } from '../api/helper';
export function AddProvider(props: {
suggestedProvider: string;
domain: string;
}) {
const { suggestedProvider, domain } = props;
const [providers, setProviders] = useState<string[]>([]);
const [selectedProvider, setSelectedProvider] =
useState<string>(suggestedProvider);
useEffect(() => {
const providers = Object.values(providerConfig)
.filter((provider) => !provider.isCustom && provider.canSelfHost)
.map((provider) => provider.name);
setProviders(providers);
}, []);
const addProvider = () => {
if (!selectedProvider) return;
addCustomProvider(domain, selectedProvider).then(async () => {
addGitProvider(domain, selectedProvider);
const cmd = {
cmd: 'init',
};
const tab = await getCurrentTab();
Browser.tabs.sendMessage(tab.id ?? 0, cmd);
// reload the popup to show the settings.
window.location.reload();
});
};
return (
<Box sx={{ p: 2 }}>
<Typography variant='body1'>
Select a provider configuration to add to the domain.
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<FormControl fullWidth size='small'>
<InputLabel>Provider Configuration</InputLabel>
<Select
id='select-provider-config'
label='Provider Configuration'
onChange={(e) => setSelectedProvider(e.target.value as string)}
value={selectedProvider}
>
{providers.map((provider) => (
<MenuItem key={provider} value={provider}>
{provider}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Button onClick={addProvider} variant='contained'>
Add custom provider
</Button>
</Box>
</Box>
);
}

View File

@ -0,0 +1,24 @@
import { Box, Button, Typography } from '@mui/material';
import { requestAccess } from '../api/access';
import { getCurrentTab } from '../api/helper';
export function AskForAccess() {
return (
<Box sx={{ p: 2 }}>
<Typography variant='body1'>
This page requires access to display icons. Please click the button
below to grant access.
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Button
variant='contained'
onClick={() => {
getCurrentTab().then(requestAccess);
}}
>
Grant Access
</Button>
</Box>
</Box>
);
}

View File

@ -0,0 +1,51 @@
import { IconSize } from '@/lib/icon-sizes';
import { getConfig, hardDefaults, setConfig } from '@/lib/user-config';
import { DomainSettingsControls } from '@/ui/shared/domain-settings-controls';
import { IconPackValue } from 'material-icon-theme';
import { useEffect, useState } from 'react';
export function DomainSettings({ domain }: { domain: string }) {
const [extensionEnabled, setExtensionEnabled] = useState<boolean>(
hardDefaults.extEnabled
);
const [iconSize, setIconSize] = useState<IconSize>(hardDefaults.iconSize);
const [iconPack, setIconPack] = useState<IconPackValue>(
hardDefaults.iconPack
);
const changeVisibility = (visible: boolean) => {
setConfig('extEnabled', visible, domain);
setExtensionEnabled(visible);
};
const updateIconSize = (iconSize: IconSize) => {
setConfig('iconSize', iconSize, domain);
setIconSize(iconSize);
};
const updateIconPack = (iconPack: IconPackValue) => {
setConfig('iconPack', iconPack, domain);
setIconPack(iconPack);
};
useEffect(() => {
getConfig('extEnabled', domain).then((enabled) =>
setExtensionEnabled(enabled)
);
getConfig('iconSize', domain).then((size) => setIconSize(size));
getConfig('iconPack', domain).then((pack) => setIconPack(pack));
}, []);
return (
<div className='domain-settings'>
<DomainSettingsControls
iconSize={iconSize}
iconPack={iconPack}
extensionEnabled={extensionEnabled}
changeVisibility={changeVisibility}
changeIconSize={updateIconSize}
changeIconPack={updateIconPack}
/>
</div>
);
}

View File

@ -0,0 +1,28 @@
import { CircularProgress, useTheme } from '@mui/material';
import { CSSProperties } from 'react';
export function LoadingSpinner() {
const theme = useTheme();
const containerStyles: CSSProperties = {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
textAlign: 'center',
width: '100%',
padding: '2.5rem 0 0 0',
boxSizing: 'border-box',
fontSize: '1rem',
lineHeight: '1.5',
};
return (
<div className='loading-spinner' style={containerStyles}>
<CircularProgress
style={{ fontSize: '2rem', color: theme.palette.warning.main }}
/>
<p>Loading...</p>
</div>
);
}

View File

@ -0,0 +1,131 @@
import { Logo } from '@/ui/shared/logo';
import { theme } from '@/ui/shared/theme';
import SettingsIcon from '@mui/icons-material/Settings';
import {
AppBar,
CssBaseline,
IconButton,
Toolbar,
Tooltip,
Typography,
} from '@mui/material';
import Box from '@mui/material/Box';
import { SxProps, Theme, ThemeProvider } from '@mui/material/styles';
import { useEffect, useState } from 'react';
import Browser from 'webextension-polyfill';
import { Footer } from '../../shared/footer';
import { getCurrentTab, getDomainFromCurrentTab } from '../api/helper';
import { PageState, checkPageState } from '../api/page-state';
import { guessProvider } from '../api/provider';
import { AddProvider } from './add-provider';
import { AskForAccess } from './ask-for-access';
import { DomainSettings } from './domain-settings';
import { LoadingSpinner } from './loading-spinner';
import { NotSupported } from './not-supported';
function SettingsPopup() {
const [domain, setDomain] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(true);
const [pageSupported, setPageSupported] = useState<boolean>(false);
const [showAskForAccess, setShowAskForAccess] = useState<boolean>(false);
const [showAddProvider, setShowAddProvider] = useState<boolean>(false);
const [suggestedProvider, setSuggestedProvider] = useState<string>('');
useEffect(() => {
getDomainFromCurrentTab().then((domain) => setDomain(domain));
}, []);
useEffect(() => {
getCurrentTab()
.then(checkPageState)
.then(async (state) => {
switch (state) {
case PageState.Supported:
setPageSupported(true);
break;
case PageState.AskForAccess:
setShowAskForAccess(true);
break;
case PageState.HasAccess:
const tab = await getCurrentTab();
const match = await guessProvider(tab);
setSuggestedProvider(match);
if (match) {
setPageSupported(true);
setShowAddProvider(true);
} else {
setPageSupported(false);
}
break;
}
setIsLoading(false);
})
.catch(() => {
// If there is an error, we assume the page is not supported
setPageSupported(false);
setIsLoading(false);
});
}, [domain]);
const openOptions = () => {
Browser.runtime.openOptionsPage();
};
const containerStyles: SxProps<Theme> = {
width: '20rem',
color: 'text.primary',
borderRadius: 0,
bgcolor: 'background.default',
minHeight: '10rem',
};
const toolbarStyles: SxProps<Theme> = {
display: 'flex',
justifyContent: 'space-between',
width: '100%',
alignItems: 'center',
boxSizing: 'border-box',
};
const shouldShowDomainSettings =
!isLoading && pageSupported && !showAddProvider;
const shouldShowNotSupported =
!isLoading && !pageSupported && !showAskForAccess;
return (
<Box sx={containerStyles}>
<AppBar position='static'>
<Toolbar sx={toolbarStyles}>
<Logo />
<Typography variant='h6' component='div'>
Material Icons
</Typography>
<Tooltip title='Configure Domains'>
<IconButton aria-label='Open options' onClick={openOptions}>
<SettingsIcon style={{ color: 'white' }} />
</IconButton>
</Tooltip>
</Toolbar>
</AppBar>
{shouldShowDomainSettings && <DomainSettings domain={domain} />}
{shouldShowNotSupported && <NotSupported />}
{showAskForAccess && <AskForAccess />}
{showAddProvider && (
<AddProvider domain={domain} suggestedProvider={suggestedProvider} />
)}
{isLoading && <LoadingSpinner />}
<Footer />
</Box>
);
}
export default function Main() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<SettingsPopup />
</ThemeProvider>
);
}

View File

@ -0,0 +1,34 @@
import WarningIcon from '@mui/icons-material/Warning';
import { useTheme } from '@mui/material';
import { CSSProperties } from 'react';
export function NotSupported() {
const theme = useTheme();
const containerStyles: CSSProperties = {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
textAlign: 'center',
width: '100%',
padding: '2rem',
boxSizing: 'border-box',
fontSize: '1rem',
lineHeight: '1.5',
};
return (
<div className='not-supported' style={containerStyles}>
<WarningIcon
style={{ fontSize: '2rem', color: theme.palette.warning.main }}
/>
<h3>Not Supported</h3>
<p style={{ margin: 0 }}>
This page is not supported by the extension. You can still use the
extension on other pages.
</p>
</div>
);
}

View File

@ -1,194 +1,24 @@
body {
padding: 0;
margin: 0;
color: #1a202c;
font-size: 16px;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
/** firefox has bigger popup width limit, this is to bring it to parity with chromium **/
max-width: 300px;
}
#content {
min-width: 250px;
.toolbar-spacer {
flex: 1 1 auto;
}
.domain-settings {
padding: 1rem;
display: flex;
justify-content: flex-start;
align-items: center;
flex-direction: column;
align-items: stretch;
padding: 1.5rem;
}
#not-supported {
display: none;
}
& > * {
width: 100%;
#domain-settings {
display: none;
}
#settings-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
#logo {
margin-right: auto;
height: 24px;
width: 24px;
}
#settings-header h1 {
margin: 0;
font-size: 1.4rem;
font-weight: bold;
color: #01579b;
}
#options-btn {
margin-left: auto;
width: 24px;
height: 24px;
filter: grayscale(1);
}
#options-btn:hover {
cursor: pointer;
filter: brightness(0.8);
}
#domain {
text-align: center;
padding: 0.5rem 0;
margin: 0.5rem 0;
border-top: solid 1px #dfe1e4;
border-bottom: solid 1px #dfe1e4;
}
#domain h2 {
margin: 0;
font-size: 1.1rem;
font-weight: normal;
}
#default-disabled-note {
color: maroon;
margin: 2rem 0;
display: none;
flex: 1;
}
#options-link {
color: #01579b;
cursor: pointer;
}
#options-link:hover {
filter: brightness(1.2);
}
label {
display: block;
font-weight: 500;
margin: 0 0.75rem 0.5rem 0;
line-height: 1.5;
}
.checkbox-wrapper {
display: flex;
justify-content: space-between;
}
.select-wrapper {
position: relative;
width: 100%;
height: fit-content;
margin-bottom: 20px;
}
select {
padding: 0 2rem;
width: 100%;
min-width: 0px;
outline: transparent solid 2px;
outline-offset: 2px;
position: relative;
appearance: none;
transition: 200ms all;
padding-bottom: 1px;
line-height: normal;
font-size: 1rem;
padding-inline-start: 1rem;
height: 2.5rem;
border-radius: 0.375rem;
border-width: 1px;
color: #2d3748;
border-color: #e2e8f0;
}
select:hover {
border-color: #cbd5e0;
}
select:focus-visible {
border-color: #3182ce;
box-shadow: #3182ce 0px 0px 0px 1px;
}
.select-icon-wrapper {
position: absolute;
top: 50%;
right: 0.5rem;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 100%;
color: currentcolor;
font-size: 1.25rem;
pointer-events: none;
}
svg.select-icon {
width: 1em;
height: 1em;
color: currentcolor;
}
#footer {
display: flex;
align-items: center;
justify-content: center;
}
#provider-wrapper {
display: none;
}
#add-provider {
display: none;
}
#request {
display: none;
}
.btn {
color: #5c6068;
padding: 0.3rem 1rem;
outline: transparent solid 2px;
outline-offset: 2px;
transition: 200ms all;
font-size: 1rem;
border-radius: 0.375rem;
border-width: 1px;
border-color: transparent;
background-color: transparent;
margin-bottom: 1rem;
}
.btn:hover {
border-color: red;
color: red;
cursor: pointer;
&:not(:last-child) {
margin-bottom: 1.5rem;
}
}
}

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>

Before

Width:  |  Height:  |  Size: 814 B

View File

@ -1,148 +1,11 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Material Icons</title>
<script type="module" crossorigin src="settings-popup.js"></script>
<link rel="stylesheet" href="settings-popup.css" />
</head>
<body>
<div id="content">
<div id="settings">
<div id="settings-header">
<img id="logo" src="/icon-128.png" />
<h1>Settings</h1>
<img id="options-btn" src="settings.svg" />
</div>
<div id="domain">
<h2 id="domain-name"></h2>
</div>
<div id="request">
We need access to the website to show the icons. If you click the
button bellow the browser will ask you for permision in another popup,
this one will close. If you allow the permission you will have to come
back to finish the setup.
<button type="button" id="request-access" class="btn">
<span>Allow access to website</span>
</button>
</div>
<div id="not-supported">
<span id="unsupported-domain"></span> is not supported by this
extension
</div>
<div id="default-disabled-note">
All icon replacements are disabled for all domains. To change this
setting, go to the
<a id="options-link">options page</a>
</div>
<div id="domain-settings">
<div id="enable-wrapper">
<div class="checkbox-wrapper">
<label for="enabled">Enable icons</label>
<input type="checkbox" id="enabled" />
</div>
</div>
<div id="icon-size-wrapper">
<label for="icon-size">Icon Size</label>
<div class="select-wrapper">
<select name="icon-size" id="icon-size">
<option value="sm">Small</option>
<option value="md">Medium</option>
<option value="lg">Large</option>
<option value="xl">Extra Large</option>
</select>
<!-- down arrow -->
<div class="select-icon-wrapper">
<svg
viewBox="0 0 24 24"
role="presentation"
class="select-icon"
focusable="false"
aria-hidden="true"
>
<path
fill="currentColor"
d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"
></path>
</svg>
</div>
</div>
</div>
<div id="icon-pack-wrapper">
<label for="icon-pack">Icon Pack</label>
<div class="select-wrapper">
<select name="icon-pack" id="icon-pack">
<option value="angular">Angular</option>
<option value="angular_ngrx">Angular + Ngrx</option>
<option value="react">React</option>
<option value="react_redux">React + Redux</option>
<option value="qwik">Qwik</option>
<option value="vue">Vue</option>
<option value="vue_vuex">Vue + Vuex</option>
<option value="nest">Nest</option>
<option value="none">None</option>
</select>
<!-- down arrow -->
<div class="select-icon-wrapper">
<svg
viewBox="0 0 24 24"
role="presentation"
class="select-icon"
focusable="false"
aria-hidden="true"
>
<path
fill="currentColor"
d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"
></path>
</svg>
</div>
</div>
</div>
<div id="provider-wrapper">
<label for="provider">Provider Configuration</label>
<div class="select-wrapper">
<select name="provider" id="provider"></select>
<!-- down arrow -->
<div class="select-icon-wrapper">
<svg
viewBox="0 0 24 24"
role="presentation"
class="select-icon"
focusable="false"
aria-hidden="true"
>
<path
fill="currentColor"
d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"
></path>
</svg>
</div>
</div>
</div>
<button type="button" id="add-provider" class="btn">
<span>Add custom provider</span>
</button>
</div>
<div id="footer">
<a
href="https://github.com/material-extensions/material-icons-browser-extension"
target="_blank"
><img src="settings-popup.github-logo.svg"
/></a>
</div>
</div>
</div>
<script src="settings-popup.js"></script>
<div id="settings-popup"></div>
</body>
</html>

View File

@ -1,295 +0,0 @@
import { IconPackValue } from 'material-icon-theme';
import Browser from 'webextension-polyfill';
import { addCustomProvider } from '../../lib/custom-providers';
import { IconSize } from '../../lib/icon-sizes';
import { getConfig, setConfig } from '../../lib/user-config';
import {
addGitProvider,
getGitProvider,
providerConfig,
} from '../../providers';
const isPageSupported = (domain: string) => getGitProvider(domain);
function getCurrentTab() {
const queryOptions = { active: true, currentWindow: true };
return Browser.tabs.query(queryOptions).then(([tab]) => tab);
}
function registerControls(domain: string) {
getConfig<IconSize>('iconSize', domain).then((size) => {
getElementByIdOrThrow<HTMLInputElement>('icon-size').value = size;
});
const updateIconSize = (event: Event) =>
setConfig('iconSize', (event.target as HTMLInputElement).value, domain);
document
?.getElementById('icon-size')
?.addEventListener('change', updateIconSize);
getConfig<IconPackValue>('iconPack', domain).then((pack) => {
getElementByIdOrThrow<HTMLInputElement>('icon-pack').value = pack;
});
const updateIconPack = (event: Event) =>
setConfig('iconPack', (event.target as HTMLInputElement).value, domain);
document
?.getElementById('icon-pack')
?.addEventListener('change', updateIconPack);
getConfig<boolean>('extEnabled', domain).then((enabled) => {
getElementByIdOrThrow<HTMLInputElement>('enabled').checked = enabled;
});
const updateExtEnabled = (event: Event) =>
setConfig('extEnabled', (event.target as HTMLInputElement).checked, domain);
document
?.getElementById('enabled')
?.addEventListener('change', updateExtEnabled);
document
.getElementById('options-btn')
?.addEventListener('click', () => Browser.runtime.openOptionsPage());
}
function setDomain(domain: string) {
getElementByIdOrThrow('domain-name').innerText = domain;
}
function displayDomainSettings() {
getElementByIdOrThrow('domain-settings').style.display = 'block';
}
function displayPageNotSupported(domain: string) {
getElementByIdOrThrow('unsupported-domain').innerText = domain;
getElementByIdOrThrow('not-supported').style.display = 'block';
}
function askDomainAccess(tab: Browser.Tabs.Tab) {
getElementByIdOrThrow('request').style.display = 'block';
const clicked = () => {
requestAccess(tab);
};
getElementByIdOrThrow('request-access').addEventListener('click', clicked);
}
function displayCustomDomain(
tab: Browser.Tabs.Tab,
domain: string,
suggestedProvider: string
) {
getElementByIdOrThrow('enable-wrapper').style.display = 'none';
getElementByIdOrThrow('icon-size-wrapper').style.display = 'none';
getElementByIdOrThrow('icon-pack-wrapper').style.display = 'none';
const btn = getElementByIdOrThrow('add-provider');
const providerEl = getElementByIdOrThrow('provider-wrapper');
btn.style.display = 'block';
providerEl.style.display = 'block';
const select = providerEl.querySelector(
'#provider'
) as HTMLInputElement | null;
for (const provider of Object.values(providerConfig)) {
if (!provider.isCustom && provider.canSelfHost) {
const selected = provider.name === suggestedProvider;
const opt = new Option(provider.name, provider.name, selected, selected);
select?.append(opt);
}
}
const addProvider = () => {
if (!select) return;
addCustomProvider(domain, select.value).then(() => {
addGitProvider(domain, select.value);
const cmd = {
cmd: 'init',
};
Browser.tabs.sendMessage(tab.id ?? 0, cmd);
// reload the popup to show the settings.
window.location.reload();
});
};
btn.addEventListener('click', addProvider);
}
function displayAllDisabledNote() {
getConfig('extEnabled', 'default').then((enabled) => {
if (enabled) return;
getElementByIdOrThrow('default-disabled-note').style.display = 'block';
getElementByIdOrThrow('domain-settings').style.display = 'none';
document
.getElementById('options-link')
?.addEventListener('click', () => Browser.runtime.openOptionsPage());
});
}
function guessProvider(tab: Browser.Tabs.Tab) {
const possibilities: Record<string, string> = {};
for (const provider of Object.values(providerConfig)) {
if (
!provider.isCustom &&
provider.canSelfHost &&
provider.selectors.detect
) {
possibilities[provider.name] = provider.selectors.detect;
}
}
const cmd = {
cmd: 'guessProvider',
args: [possibilities],
};
return Browser.tabs.sendMessage(tab.id ?? 0, cmd).then((match) => {
if (match === null) {
return false;
}
return match;
});
}
function getElementByIdOrThrow<T = HTMLElement>(id: string): NonNullable<T> {
const el = document.getElementById(id) as T | null;
if (!el) {
throw new Error(`Element with id ${id} not found`);
}
return el;
}
function checkAccess(tab: Browser.Tabs.Tab) {
const { host } = new URL(tab.url ?? '');
const perm = {
permissions: ['activeTab'],
origins: [`*://${host}/*`],
};
return Browser.permissions.contains(perm).then(async (r) => {
if (r) {
await ensureContentScriptRegistered(tab);
return tab;
}
return false;
});
}
function requestAccess(tab: Browser.Tabs.Tab) {
const { host } = new URL(tab.url ?? '');
const perm: Browser.Permissions.Permissions = {
permissions: ['activeTab'],
origins: [`*://${host}/*`],
};
// request the permission
Browser.permissions.request(perm).then(async (granted: boolean) => {
if (!granted) {
return;
}
// when granted reload the popup to show ui changes
window.location.reload();
});
// close the popup, in firefox it stays open for some reason.
window.close();
}
async function ensureContentScriptRegistered(tab: Browser.Tabs.Tab) {
const { host } = new URL(tab.url ?? '');
const scripts = await Browser.scripting.getRegisteredContentScripts({
ids: ['material-icons'],
});
const pattern: string = `*://${host}/*`;
if (!scripts.length) {
// run the script now in the current tab to prevent need for reloading
await Browser.scripting.executeScript({
files: ['./main.js'],
target: {
tabId: tab.id ?? 0,
},
});
// register content script for future use
return Browser.scripting.registerContentScripts([
{
id: 'material-icons',
js: ['./main.js'],
css: ['./injected-styles.css'],
matches: [pattern],
runAt: 'document_start',
},
]);
}
const matches = scripts[0].matches ?? [];
// if we somehow already registered the script for requested origin, skip it
if (matches.includes(pattern)) {
return;
}
// add new origin to content script
return Browser.scripting.updateContentScripts([
{
id: 'material-icons',
matches: [...matches, pattern],
},
]);
}
function doGuessProvider(tab: Browser.Tabs.Tab, domain: string) {
return guessProvider(tab).then((match) => {
if (match !== false) {
registerControls(domain);
displayDomainSettings();
return displayCustomDomain(tab, domain, match);
}
return displayPageNotSupported(domain);
});
}
function init(tab: Browser.Tabs.Tab) {
const domain = new URL(tab.url ?? '').host;
setDomain(domain);
isPageSupported(domain).then((supported) => {
if (!supported) {
// we are in some internal browser page, not supported.
if (tab.url && !tab.url.startsWith('http')) {
return displayPageNotSupported(domain);
}
return checkAccess(tab).then((access) => {
if (access === false) {
return askDomainAccess(tab);
}
return doGuessProvider(tab, domain);
});
}
registerControls(domain);
displayDomainSettings();
displayAllDisabledNote();
});
}
getCurrentTab().then(init);

View File

@ -0,0 +1,14 @@
import { StyledEngineProvider } from '@mui/material/styles';
import * as React from 'react';
import * as ReactDOM from 'react-dom/client';
import SettingsPopup from './components/main';
ReactDOM.createRoot(
document.getElementById('settings-popup') as HTMLElement
).render(
<React.StrictMode>
<StyledEngineProvider injectFirst>
<SettingsPopup />
</StyledEngineProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,87 @@
import { IconSize, iconSizes } from '@/lib/icon-sizes';
import { hardDefaults } from '@/lib/user-config';
import {
Checkbox,
FormControl,
FormControlLabel,
InputLabel,
MenuItem,
Select,
} from '@mui/material';
import { IconPackValue, availableIconPacks } from 'material-icon-theme';
import { snakeToTitleCase } from './utils';
type DomainSettingsControls = {
extensionEnabled: boolean;
iconSize: IconSize | undefined;
iconPack: IconPackValue | undefined;
changeVisibility: (visible: boolean) => void;
changeIconSize: (iconSize: IconSize) => void;
changeIconPack: (iconPack: IconPackValue) => void;
};
export function DomainSettingsControls({
extensionEnabled,
iconSize,
iconPack,
changeVisibility,
changeIconSize,
changeIconPack,
}: DomainSettingsControls) {
const displayIconSize = (iconSize: IconSize) => {
switch (iconSize) {
case 'sm':
return 'Small';
case 'md':
return 'Medium';
case 'lg':
return 'Large';
case 'xl':
return 'Extra Large';
}
};
return (
<>
<FormControlLabel
control={
<Checkbox
checked={extensionEnabled ?? hardDefaults.extEnabled}
onChange={(e) => changeVisibility(e.target.checked)}
/>
}
label='Enable icons'
/>
<FormControl fullWidth size='small'>
<InputLabel>Icon Size</InputLabel>
<Select
id='select-icon-size'
label='Icon Size'
value={iconSize}
onChange={(e) => changeIconSize(e.target.value as IconSize)}
>
{iconSizes.map((size) => (
<MenuItem key={size} value={size}>
{displayIconSize(size)}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth size='small'>
<InputLabel>Icon Pack</InputLabel>
<Select
id='select-icon-pack'
label='Icon Pack'
value={iconPack}
onChange={(e) => changeIconPack(e.target.value as IconPackValue)}
>
{availableIconPacks.map((pack) => (
<MenuItem key={pack} value={pack}>
{snakeToTitleCase(pack)}
</MenuItem>
))}
</Select>
</FormControl>
</>
);
}

30
src/ui/shared/footer.tsx Normal file
View File

@ -0,0 +1,30 @@
import { Box, Link, SxProps, Theme } from '@mui/material';
export function Footer() {
const styles: SxProps<Theme> = {
width: '100%',
color: 'text.primary',
borderRadius: 0,
display: 'flex',
justifyContent: 'center',
gap: '.5rem',
padding: '1rem 0',
};
return (
<Box sx={styles}>
<Link
target='_blank'
href='https://github.com/material-extensions/material-icons-browser-extension'
>
GitHub
</Link>
<Link
target='_blank'
href='https://github.com/material-extensions/material-icons-browser-extension/issues'
>
Report Issue
</Link>
</Box>
);
}

View File

@ -0,0 +1,61 @@
import InfoIcon from '@mui/icons-material/Info';
import { Button, IconButton, Popover, Typography } from '@mui/material';
import { CSSProperties } from '@mui/material/styles/createTypography';
import { MouseEvent, useState } from 'react';
export function InfoPopover({
infoText,
renderContent,
}: {
infoText: string;
renderContent: () => JSX.Element;
}) {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const open = Boolean(anchorEl);
const id = open ? 'simple-popover' : undefined;
const containerStyles: CSSProperties = {
display: 'grid',
width: '100%',
gridTemplateColumns: '1fr 4rem',
gap: '.5rem',
};
const contentStyles: CSSProperties = {
display: 'flex',
alignItems: 'center',
width: '100%',
};
return (
<div style={containerStyles}>
<div style={contentStyles}>{renderContent()}</div>
<div style={contentStyles}>
<IconButton onClick={handleClick} color='primary'>
<InfoIcon />
</IconButton>
</div>
<Popover
id={id}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
>
<Typography sx={{ p: 2 }}>{infoText}</Typography>
</Popover>
</div>
);
}

5
src/ui/shared/logo.tsx Normal file
View File

@ -0,0 +1,5 @@
const logo = require('./../../logo.svg') as string;
export function Logo() {
return <img src={logo} height={25} width={25} />;
}

7
src/ui/shared/theme.ts Normal file
View File

@ -0,0 +1,7 @@
import { createTheme } from '@mui/material';
export const theme = createTheme({
colorSchemes: {
dark: true,
},
});

6
src/ui/shared/utils.ts Normal file
View File

@ -0,0 +1,6 @@
export function snakeToTitleCase(value: string): string {
return value
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}

View File

@ -3,12 +3,17 @@
"target": "es6",
"module": "commonjs",
"outDir": "./dist",
"baseUrl": "./",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"lib": ["es2022", "dom"]
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"paths": {
"@/*": ["src/*"]
}
},
"include": ["./src/**/*.ts"],
"include": ["./src"],
"exclude": ["node_modules"]
}