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:
parent
061bba8b60
commit
17c71b886e
2934
package-lock.json
generated
2934
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@ -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",
|
||||
|
@ -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)
|
||||
|
@ -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 });
|
||||
});
|
||||
};
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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, {
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
19
src/main.ts
19
src/main.ts
@ -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
|
||||
) => {
|
||||
|
@ -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>;
|
||||
|
@ -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,
|
||||
|
@ -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)) {
|
||||
|
15
src/ui/options/api/domains.ts
Normal file
15
src/ui/options/api/domains.ts
Normal 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,
|
||||
}))
|
||||
),
|
||||
]);
|
||||
}
|
39
src/ui/options/api/icons.ts
Normal file
39
src/ui/options/api/icons.ts
Normal 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-', ''));
|
||||
}
|
22
src/ui/options/api/language-ids.ts
Normal file
22
src/ui/options/api/language-ids.ts
Normal 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();
|
||||
}
|
37
src/ui/options/components/confirm-dialog.tsx
Normal file
37
src/ui/options/components/confirm-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
61
src/ui/options/components/domain-actions.tsx
Normal file
61
src/ui/options/components/domain-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
22
src/ui/options/components/domain-name.tsx
Normal file
22
src/ui/options/components/domain-name.tsx
Normal 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>
|
||||
);
|
||||
}
|
113
src/ui/options/components/domain-settings.tsx
Normal file
113
src/ui/options/components/domain-settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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'
|
||||
/>
|
||||
);
|
||||
}
|
@ -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'
|
||||
/>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
35
src/ui/options/components/icon-settings/icon-preview.tsx
Normal file
35
src/ui/options/components/icon-settings/icon-preview.tsx
Normal 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`}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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'
|
||||
/>
|
||||
);
|
||||
}
|
138
src/ui/options/components/main.tsx
Normal file
138
src/ui/options/components/main.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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))
|
||||
);
|
||||
});
|
12
src/ui/options/options.tsx
Normal file
12
src/ui/options/options.tsx
Normal 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>
|
||||
);
|
9
src/ui/options/types/binding-control-props.ts
Normal file
9
src/ui/options/types/binding-control-props.ts
Normal 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 }
|
||||
);
|
88
src/ui/popup/api/access.ts
Normal file
88
src/ui/popup/api/access.ts
Normal 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],
|
||||
},
|
||||
]);
|
||||
}
|
28
src/ui/popup/api/helper.ts
Normal file
28
src/ui/popup/api/helper.ts
Normal 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;
|
||||
});
|
||||
}
|
28
src/ui/popup/api/page-state.ts
Normal file
28
src/ui/popup/api/page-state.ts
Normal 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;
|
||||
}
|
||||
};
|
24
src/ui/popup/api/provider.ts
Normal file
24
src/ui/popup/api/provider.ts
Normal 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;
|
||||
}
|
79
src/ui/popup/components/add-provider.tsx
Normal file
79
src/ui/popup/components/add-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
24
src/ui/popup/components/ask-for-access.tsx
Normal file
24
src/ui/popup/components/ask-for-access.tsx
Normal 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>
|
||||
);
|
||||
}
|
51
src/ui/popup/components/domain-settings.tsx
Normal file
51
src/ui/popup/components/domain-settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
28
src/ui/popup/components/loading-spinner.tsx
Normal file
28
src/ui/popup/components/loading-spinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
131
src/ui/popup/components/main.tsx
Normal file
131
src/ui/popup/components/main.tsx
Normal 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>
|
||||
);
|
||||
}
|
34
src/ui/popup/components/not-supported.tsx
Normal file
34
src/ui/popup/components/not-supported.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 |
@ -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>
|
||||
|
@ -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);
|
14
src/ui/popup/settings-popup.tsx
Normal file
14
src/ui/popup/settings-popup.tsx
Normal 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>
|
||||
);
|
87
src/ui/shared/domain-settings-controls.tsx
Normal file
87
src/ui/shared/domain-settings-controls.tsx
Normal 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
30
src/ui/shared/footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
61
src/ui/shared/info-popover.tsx
Normal file
61
src/ui/shared/info-popover.tsx
Normal 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
5
src/ui/shared/logo.tsx
Normal 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
7
src/ui/shared/theme.ts
Normal 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
6
src/ui/shared/utils.ts
Normal 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(' ');
|
||||
}
|
@ -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"]
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user