mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-10 03:32:52 +00:00
feat: type-safe sc-icons
This commit is contained in:
@@ -50,11 +50,11 @@ the config file.
|
||||
Icon font minification is done automatically, but requires you to
|
||||
follow a few simple rules:
|
||||
|
||||
1. Use the tagged template literal for referencing icon names in
|
||||
1. Use the type-safe proxy for referencing icon names in
|
||||
TypeScript files and code
|
||||
|
||||
```ts
|
||||
SCIcon`icon_name`;
|
||||
SCIcon.icon_name;
|
||||
```
|
||||
|
||||
2. When using `ion-icon` in HTML, reference either icons that went through
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import type {IconConfig} from './scripts/icon-config';
|
||||
|
||||
const config: IconConfig = {
|
||||
/** @type {import('./scripts/icon-config').IconConfig} */
|
||||
const config = {
|
||||
inputPath: 'node_modules/material-symbols/material-symbols-rounded.woff2',
|
||||
outputPath: 'src/assets/icons.min.woff2',
|
||||
htmlGlob: 'src/**/*.html',
|
||||
@@ -38,10 +38,6 @@ const config: IconConfig = {
|
||||
'work',
|
||||
],
|
||||
},
|
||||
codePoints: {
|
||||
ios_share: 'e6b8',
|
||||
fact_check: 'f0c5',
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -21,7 +21,7 @@
|
||||
"build:prod": "ng build --configuration=production",
|
||||
"build:stats": "ng build --configuration=production --stats-json",
|
||||
"changelog": "conventional-changelog -p angular -i src/assets/about/CHANGELOG.md -s -r 0",
|
||||
"check-icons": "ts-node scripts/check-icon-correctness.ts",
|
||||
"check-icons": "node scripts/check-icon-correctness.mjs",
|
||||
"chromium:no-cors": "chromium --disable-web-security --user-data-dir=\".browser-data/chromium\"",
|
||||
"chromium:virtual-host": "chromium --host-resolver-rules=\"MAP mobile.app.uni-frankfurt.de:* localhost:8100\" --ignore-certificate-errors",
|
||||
"cypress:open": "cypress open",
|
||||
@@ -35,10 +35,16 @@
|
||||
"e2e": "ng e2e",
|
||||
"format": "prettier . -c",
|
||||
"format:fix": "prettier --write .",
|
||||
"licenses": "license-checker --json > src/assets/about/licenses.json && ts-node ./scripts/accumulate-licenses.ts && git add src/assets/about/licenses.json",
|
||||
"licenses": "license-checker --json > src/assets/about/licenses.json && node ./scripts/accumulate-licenses.mjs && git add src/assets/about/licenses.json",
|
||||
"lint": "ng lint && stylelint \"**/*.scss\"",
|
||||
"lint:fix": "eslint --fix -c .eslintrc.json --ignore-path .eslintignore --ext .ts,.html src/ && stylelint --fix \"**/*.scss\"",
|
||||
<<<<<<< HEAD
|
||||
"minify-icons": "ts-node-esm scripts/minify-icon-font.ts",
|
||||
||||||| parent of 7b431b8f (feat: type-safe sc-icons)
|
||||
"minify-icons": "ts-node scripts/minify-icon-font.ts",
|
||||
=======
|
||||
"minify-icons": "node scripts/minify-icon-font.mjs",
|
||||
>>>>>>> 7b431b8f (feat: type-safe sc-icons)
|
||||
"postinstall": "jetify && echo \"skipping jetify in production mode\"",
|
||||
"preview": "http-server www --p 8101 -o",
|
||||
"push": "git push && git push origin \"v$npm_package_version\"",
|
||||
|
||||
@@ -12,33 +12,34 @@
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import {omit} from '../src/app/_helpers/collections/omit';
|
||||
import {pickBy} from '../src/app/_helpers/collections/pick';
|
||||
import {readFileSync, writeFileSync} from 'fs';
|
||||
import {omit, pickBy} from '@openstapps/collection-utils';
|
||||
|
||||
/**
|
||||
* accumulate and transform licenses based on two license files
|
||||
* @param {string} path
|
||||
* @param {string} additionalLicensesPath
|
||||
*/
|
||||
function accumulateFile(path: string, additionalLicensesPath: string) {
|
||||
const packageJson = JSON.parse(fs.readFileSync('./package.json').toString());
|
||||
function accumulateFile(path, additionalLicensesPath) {
|
||||
const packageJson = JSON.parse(readFileSync('./package.json').toString());
|
||||
const dependencies = packageJson.dependencies;
|
||||
|
||||
console.log(`Accumulating licenses from ${path}`);
|
||||
|
||||
fs.writeFileSync(
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
Object.entries<any>({
|
||||
...pickBy(JSON.parse(fs.readFileSync(path).toString()), (_, key: string) => {
|
||||
const parts = key.split('@');
|
||||
Object.entries({
|
||||
...pickBy(JSON.parse(readFileSync(path).toString()), (_, key) => {
|
||||
const parts = /** @type {string} */ (key).split('@');
|
||||
|
||||
return dependencies[parts.slice(0, -1).join('@')] === parts[parts.length - 1];
|
||||
}),
|
||||
...JSON.parse(fs.readFileSync(additionalLicensesPath).toString()),
|
||||
...JSON.parse(readFileSync(additionalLicensesPath).toString()),
|
||||
})
|
||||
.map(([key, value]) => ({
|
||||
licenseText: value.licenseFile && fs.readFileSync(value.licenseFile, 'utf8'),
|
||||
licenseText: value.licenseFile && readFileSync(value.licenseFile, 'utf8'),
|
||||
name: key,
|
||||
...omit(value, 'licenseFile', 'path'),
|
||||
}))
|
||||
@@ -12,18 +12,20 @@
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import fontkit, {Font} from 'fontkit';
|
||||
import config from '../icons.config';
|
||||
import {openSync} from 'fontkit';
|
||||
import config from '../icons.config.mjs';
|
||||
import {existsSync} from 'fs';
|
||||
import {getUsedIconsHtml, getUsedIconsTS} from './gather-used-icons';
|
||||
import {getUsedIconsHtml, getUsedIconsTS} from './gather-used-icons.mjs';
|
||||
import {fetchCodePointMap} from './get-code-points.mjs';
|
||||
|
||||
const commandName = '"npm run minify-icons"';
|
||||
const originalFont = fontkit.openSync(config.inputPath);
|
||||
if (!existsSync(config.outputPath)) {
|
||||
console.error(`Minified font not found. Run ${commandName} first.`);
|
||||
process.exit(-1);
|
||||
}
|
||||
const modifiedFont = fontkit.openSync(config.outputPath);
|
||||
|
||||
/** @type {import('fontkit').Font} */
|
||||
const modifiedFont = openSync(config.outputPath);
|
||||
|
||||
let success = true;
|
||||
|
||||
@@ -48,25 +50,16 @@ async function checkAll() {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Record<string, string[]>} icons
|
||||
*/
|
||||
function check(icons: Record<string, string[]>) {
|
||||
for (const [purpose, iconSet] of Object.entries(icons)) {
|
||||
for (const icon of iconSet) {
|
||||
if (!hasIcon(originalFont, icon)) {
|
||||
success = false;
|
||||
console.error(`${purpose}: ${icon} does not exist. Typo?`);
|
||||
} else if (!hasIcon(modifiedFont, icon)) {
|
||||
success = false;
|
||||
console.error(`${purpose}: ${icon} not found in minified font. Run ${commandName} to regenerate it.`);
|
||||
}
|
||||
async function check(icons) {
|
||||
const codePoints = await fetchCodePointMap();
|
||||
|
||||
for (const icon of Object.values(icons).flat()) {
|
||||
const codePoint = codePoints.get(icon);
|
||||
if (!codePoint) throw new Error(`"${icon}" is not a valid icon`);
|
||||
if (!modifiedFont.getGlyph(Number.parseInt(codePoint, 16))) {
|
||||
throw new Error(`"${icon}" (code point ${codePoint}) is missing`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function hasIcon(font: Font, icon: string) {
|
||||
return font.layout(icon).glyphs.some(it => it.isLigature);
|
||||
}
|
||||
@@ -14,34 +14,39 @@
|
||||
*/
|
||||
import {glob} from 'glob';
|
||||
import {readFileSync} from 'fs';
|
||||
import {matchPropertyContent, matchTagProperties} from '../src/app/util/ion-icon/icon-match';
|
||||
import {
|
||||
matchPropertyAccess,
|
||||
matchPropertyContent,
|
||||
matchTagProperties,
|
||||
} from '../src/app/util/ion-icon/icon-match.mjs';
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<Record<string, string[]>>}
|
||||
*/
|
||||
export async function getUsedIconsHtml(pattern = 'src/**/*.html'): Promise<Record<string, string[]>> {
|
||||
export async function getUsedIconsHtml(pattern = 'src/**/*.html') {
|
||||
return Object.fromEntries(
|
||||
(await glob(pattern))
|
||||
.map(file => [
|
||||
file,
|
||||
(readFileSync(file, 'utf8')
|
||||
readFileSync(file, 'utf8')
|
||||
.match(matchTagProperties('ion-icon'))
|
||||
?.flatMap(match => {
|
||||
return match.match(matchPropertyContent(['name', 'md', 'ios']));
|
||||
})
|
||||
.filter(it => !!it) as string[]) || [],
|
||||
.filter(it => !!it) || [],
|
||||
])
|
||||
.filter(([, values]) => values.length > 0),
|
||||
.filter(([, values]) => values && values.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<Record<string, string[]>>}
|
||||
*/
|
||||
export async function getUsedIconsTS(pattern = 'src/**/*.ts'): Promise<Record<string, string[]>> {
|
||||
export async function getUsedIconsTS(pattern = 'src/**/*.ts') {
|
||||
const regex = matchPropertyAccess('SCIcon');
|
||||
return Object.fromEntries(
|
||||
(await glob(pattern))
|
||||
.map(file => [file, readFileSync(file, 'utf8').match(/(?<=Icon`)[\w-]+(?=`)/g) || []])
|
||||
.filter(([, values]) => values.length > 0),
|
||||
.map(file => [file, readFileSync(file, 'utf8').match(regex) || []])
|
||||
.filter(([, values]) => values && values.length > 0),
|
||||
);
|
||||
}
|
||||
23
frontend/app/scripts/get-code-points.mjs
Normal file
23
frontend/app/scripts/get-code-points.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
const url =
|
||||
'https://raw.githubusercontent.com/google/material-design-icons/master/' +
|
||||
'variablefont/MaterialSymbolsRounded%5BFILL%2CGRAD%2Copsz%2Cwght%5D.codepoints';
|
||||
|
||||
export async function fetchCodePointMap() {
|
||||
const icons = await fetch(url)
|
||||
.then(it => it.text())
|
||||
.then(it => new Map(it.split('\n').map(it => /** @type {[string, string]} */ (it.split(' ')))));
|
||||
if (icons.size < 100) throw new Error(`Code point map is very small, is the URL incorrect? ${url}`);
|
||||
return icons;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} icons
|
||||
*/
|
||||
export async function getCodePoints(icons) {
|
||||
const codePoints = await fetchCodePointMap();
|
||||
return icons.map(icon => {
|
||||
const code = codePoints.get(icon);
|
||||
if (!code) throw new Error(`Code point for icon ${icon} not found`);
|
||||
return code;
|
||||
});
|
||||
}
|
||||
@@ -19,5 +19,4 @@ export interface IconConfig {
|
||||
inputPath: string;
|
||||
outputPath: string;
|
||||
additionalIcons?: {[purpose: string]: string[]};
|
||||
codePoints?: {[name: string]: string};
|
||||
}
|
||||
@@ -14,16 +14,17 @@
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import fontkit from 'fontkit';
|
||||
import {exec} from 'child_process';
|
||||
import config from '../icons.config';
|
||||
import config from '../icons.config.mjs';
|
||||
import {statSync} from 'fs';
|
||||
import {getUsedIconsHtml, getUsedIconsTS} from './gather-used-icons';
|
||||
import {getUsedIconsHtml, getUsedIconsTS} from './gather-used-icons.mjs';
|
||||
import {getCodePoints} from './get-code-points.mjs';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string[] | string} command
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function run(command: string[] | string): Promise<string> {
|
||||
async function run(command) {
|
||||
const fullCommand = Array.isArray(command) ? command.join(' ') : command;
|
||||
console.log(`>> ${fullCommand}`);
|
||||
|
||||
@@ -44,7 +45,8 @@ async function run(command: string[] | string): Promise<string> {
|
||||
*
|
||||
*/
|
||||
async function minifyIconFont() {
|
||||
const icons = new Set<string>();
|
||||
/** @type {Set<string>} */
|
||||
const icons = new Set();
|
||||
|
||||
for (const iconSet of [
|
||||
...Object.values(config.additionalIcons || []),
|
||||
@@ -56,35 +58,7 @@ async function minifyIconFont() {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Icons used:', [...icons.values()].sort());
|
||||
const font = fontkit.openSync(config.inputPath);
|
||||
|
||||
const glyphs: string[] = ['5f-7a', '30-39'];
|
||||
for (const icon of icons) {
|
||||
const iconGlyphs = font.layout(icon).glyphs;
|
||||
if (iconGlyphs.length === 0) {
|
||||
console.error(`${icon} not found in font. Typo?`);
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
const codePoints = iconGlyphs
|
||||
.flatMap(it => font.stringsForGlyph(it.id))
|
||||
.flatMap(it => [...it])
|
||||
.map(it => it.codePointAt(0)!.toString(16));
|
||||
|
||||
if (codePoints.length === 0) {
|
||||
if (config.codePoints?.[icon]) {
|
||||
glyphs.push(config.codePoints[icon]);
|
||||
} else {
|
||||
console.log();
|
||||
console.error(`${icon} code point could not be determined. Add it to config.codePoints.`);
|
||||
process.exit(-1);
|
||||
}
|
||||
}
|
||||
|
||||
glyphs.push(...codePoints);
|
||||
}
|
||||
glyphs.sort();
|
||||
const glyphs = ['5f-7a', '30-39', ...(await getCodePoints([...icons]))].sort();
|
||||
|
||||
console.log(
|
||||
await run([
|
||||
@@ -114,8 +88,10 @@ minifyIconFont();
|
||||
|
||||
/**
|
||||
* Bytes to respective units
|
||||
* @param {number} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function toByteUnit(value: number): string {
|
||||
function toByteUnit(value) {
|
||||
if (value < 1024) {
|
||||
return `${value}B`;
|
||||
} else if (value < 1024 * 1024) {
|
||||
@@ -49,7 +49,7 @@
|
||||
@if (content.type === 'router link') {
|
||||
<ion-item [routerLink]="content.link">
|
||||
@if (content.icon) {
|
||||
<ion-icon [name]="content.icon" slot="start"></ion-icon>
|
||||
<ion-icon [name]="$any(content.icon)" slot="start"></ion-icon>
|
||||
}
|
||||
<ion-label>{{ 'title' | translateSimple: content }}</ion-label>
|
||||
</ion-item>
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('ConfigProvider', () => {
|
||||
storageProviderSpy.has.and.returnValue(Promise.resolve(true));
|
||||
const wrongConfig = structuredClone(sampleIndexResponse);
|
||||
wrongConfig.backend.SCVersion = '0.1.0';
|
||||
storageProviderSpy.get.and.returnValue(wrongConfig);
|
||||
storageProviderSpy.get.and.returnValue(Promise.resolve(wrongConfig));
|
||||
spyOn(configProvider.client, 'handshake').and.returnValue(Promise.resolve(sampleIndexResponse));
|
||||
await configProvider.init();
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {AddEventStates, AddEventStatesMap} from './add-event-action-chip.config'
|
||||
import {EditEventSelectionComponent} from '../edit-event-selection.component';
|
||||
import {AddEventReviewModalComponent} from '../../../calendar/add-event-review-modal.component';
|
||||
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
||||
import {MaterialSymbol} from 'material-symbols';
|
||||
|
||||
/**
|
||||
* Shows a horizontal list of action chips
|
||||
@@ -55,7 +56,7 @@ export class AddEventActionChipComponent {
|
||||
/**
|
||||
* Icon
|
||||
*/
|
||||
icon: string;
|
||||
icon: MaterialSymbol;
|
||||
|
||||
/**
|
||||
* Current state of icon fill
|
||||
|
||||
@@ -24,28 +24,28 @@ export enum AddEventStates {
|
||||
|
||||
export const AddEventStatesMap = {
|
||||
[AddEventStates.ADDED_ALL]: {
|
||||
icon: SCIcon`event_available`,
|
||||
icon: SCIcon.event_available,
|
||||
fill: true,
|
||||
label: 'data.chips.add_events.ADDED_ALL',
|
||||
disabled: false,
|
||||
color: 'success',
|
||||
},
|
||||
[AddEventStates.ADDED_SOME]: {
|
||||
icon: SCIcon`event`,
|
||||
icon: SCIcon.event,
|
||||
fill: true,
|
||||
label: 'data.chips.add_events.ADDED_SOME',
|
||||
disabled: false,
|
||||
color: 'success',
|
||||
},
|
||||
[AddEventStates.REMOVED_ALL]: {
|
||||
icon: SCIcon`calendar_today`,
|
||||
icon: SCIcon.calendar_today,
|
||||
fill: false,
|
||||
label: 'data.chips.add_events.REMOVED_ALL',
|
||||
disabled: false,
|
||||
color: 'primary',
|
||||
},
|
||||
[AddEventStates.UNAVAILABLE]: {
|
||||
icon: SCIcon`event_busy`,
|
||||
icon: SCIcon.event_busy,
|
||||
fill: false,
|
||||
label: 'data.chips.add_events.UNAVAILABLE',
|
||||
disabled: true,
|
||||
|
||||
@@ -14,37 +14,38 @@
|
||||
*/
|
||||
import {SCThingType} from '@openstapps/core';
|
||||
import {SCIcon} from '../../util/ion-icon/icon';
|
||||
import {MaterialSymbol} from 'material-symbols';
|
||||
|
||||
export const DataIcons: Record<SCThingType, string> = {
|
||||
'academic event': SCIcon`school`,
|
||||
'assessment': SCIcon`fact_check`,
|
||||
'article': SCIcon`article`,
|
||||
'book': SCIcon`book`,
|
||||
'building': SCIcon`location_city`,
|
||||
'certification': SCIcon`contract`,
|
||||
'catalog': SCIcon`inventory_2`,
|
||||
'contact point': SCIcon`contact_page`,
|
||||
'course of study': SCIcon`school`,
|
||||
'date series': SCIcon`event`,
|
||||
'dish': SCIcon`lunch_dining`,
|
||||
'favorite': SCIcon`favorite`,
|
||||
'floor': SCIcon`foundation`,
|
||||
'id card': SCIcon`badge`,
|
||||
'message': SCIcon`newspaper`,
|
||||
'organization': SCIcon`business_center`,
|
||||
'periodical': SCIcon`feed`,
|
||||
'person': SCIcon`person`,
|
||||
'point of interest': SCIcon`pin_drop`,
|
||||
'publication event': SCIcon`campaign`,
|
||||
'room': SCIcon`meeting_room`,
|
||||
'semester': SCIcon`date_range`,
|
||||
'setting': SCIcon`settings`,
|
||||
'sport course': SCIcon`sports_soccer`,
|
||||
'study module': SCIcon`view_module`,
|
||||
'ticket': SCIcon`confirmation_number`,
|
||||
'todo': SCIcon`task`,
|
||||
'tour': SCIcon`tour`,
|
||||
'video': SCIcon`movie`,
|
||||
'diff': SCIcon`difference`,
|
||||
'job posting': SCIcon`work`,
|
||||
};
|
||||
export const DataIcons = {
|
||||
'academic event': SCIcon.school,
|
||||
'assessment': SCIcon.fact_check,
|
||||
'article': SCIcon.article,
|
||||
'book': SCIcon.book,
|
||||
'building': SCIcon.location_city,
|
||||
'certification': SCIcon.contract,
|
||||
'catalog': SCIcon.inventory_2,
|
||||
'contact point': SCIcon.contact_page,
|
||||
'course of study': SCIcon.school,
|
||||
'date series': SCIcon.event,
|
||||
'dish': SCIcon.lunch_dining,
|
||||
'favorite': SCIcon.favorite,
|
||||
'floor': SCIcon.foundation,
|
||||
'id card': SCIcon.badge,
|
||||
'message': SCIcon.newspaper,
|
||||
'organization': SCIcon.business_center,
|
||||
'periodical': SCIcon.feed,
|
||||
'person': SCIcon.person,
|
||||
'point of interest': SCIcon.pin_drop,
|
||||
'publication event': SCIcon.campaign,
|
||||
'room': SCIcon.meeting_room,
|
||||
'semester': SCIcon.date_range,
|
||||
'setting': SCIcon.settings,
|
||||
'sport course': SCIcon.sports_soccer,
|
||||
'study module': SCIcon.view_module,
|
||||
'ticket': SCIcon.confirmation_number,
|
||||
'todo': SCIcon.task,
|
||||
'tour': SCIcon.tour,
|
||||
'video': SCIcon.movie,
|
||||
'diff': SCIcon.difference,
|
||||
'job posting': SCIcon.work,
|
||||
} satisfies Record<SCThingType, MaterialSymbol>;
|
||||
|
||||
@@ -31,7 +31,7 @@ export class DataIconPipe implements PipeTransform {
|
||||
/**
|
||||
* Provide the icon name from the data type
|
||||
*/
|
||||
transform(type: SCThingType): string {
|
||||
transform(type: SCThingType) {
|
||||
return this.typeIconMap[type];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ import {SCThings} from '@openstapps/core';
|
||||
import {SCIcon} from '../../../util/ion-icon/icon';
|
||||
|
||||
const AccordionButtonState = {
|
||||
collapsed: SCIcon`expand_more`,
|
||||
expanded: SCIcon`expand_less`,
|
||||
collapsed: SCIcon.expand_more,
|
||||
expanded: SCIcon.expand_less,
|
||||
};
|
||||
|
||||
@Component({
|
||||
@@ -35,7 +35,8 @@ export class TitleCardComponent implements OnInit, OnChanges {
|
||||
|
||||
@ViewChild('accordionTextArea') accordionTextArea: ElementRef;
|
||||
|
||||
buttonState = AccordionButtonState.collapsed;
|
||||
buttonState: (typeof AccordionButtonState)[keyof typeof AccordionButtonState] =
|
||||
AccordionButtonState.collapsed;
|
||||
|
||||
buttonShown = true;
|
||||
|
||||
|
||||
@@ -35,13 +35,13 @@
|
||||
lines="none"
|
||||
class="menu-category"
|
||||
>
|
||||
<ion-icon slot="end" [name]="category.icon"></ion-icon>
|
||||
<ion-icon slot="end" [name]="$any(category.icon)"></ion-icon>
|
||||
<ion-label> {{ category.translations[language]?.title | titlecase }} </ion-label>
|
||||
</ion-item>
|
||||
}
|
||||
@for (item of category.items; track item) {
|
||||
<ion-item [rootLink]="item.route" [redirectedFrom]="item.route">
|
||||
<ion-icon slot="end" [name]="item.icon"></ion-icon>
|
||||
<ion-icon slot="end" [name]="$any(item.icon)"></ion-icon>
|
||||
<ion-label> {{ item.translations[language]?.title | titlecase }} </ion-label>
|
||||
</ion-item>
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</ion-menu-toggle>
|
||||
@for (category of menu; track category; let isFirst = $first) {
|
||||
<ion-tab-button [rootLink]="category.route" [redirectedFrom]="category.route" [tab]="category.title">
|
||||
<ion-icon [name]="category.icon"></ion-icon>
|
||||
<ion-icon [name]="$any(category.icon)"></ion-icon>
|
||||
<ion-label>{{ category.translations[language]?.title | titlecase }}</ion-label>
|
||||
</ion-tab-button>
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
>
|
||||
<div>
|
||||
@if (link.icon) {
|
||||
<ion-icon [name]="link.icon" [size]="36" color="dark"></ion-icon>
|
||||
<ion-icon [name]="$any(link.icon)" [size]="36" color="dark"></ion-icon>
|
||||
}
|
||||
<ion-label>{{ 'name' | translateSimple: link }}</ion-label>
|
||||
</div>
|
||||
|
||||
5
frontend/app/src/app/util/ion-icon/icon-match.d.mts
Normal file
5
frontend/app/src/app/util/ion-icon/icon-match.d.mts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function matchTagProperties(tag: string): RegExp;
|
||||
|
||||
export function matchPropertyContent(properties: string[]): RegExp;
|
||||
|
||||
export function matchPropertyAccess(objectName: string): RegExp;
|
||||
@@ -14,17 +14,24 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} tag
|
||||
*/
|
||||
export function matchTagProperties(tag: string) {
|
||||
export function matchTagProperties(tag) {
|
||||
return new RegExp(`(?<=<${tag})[\\s\\S]*?(?=>\\s*<\\/${tag}\\s*>)`, 'g');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string[]} properties
|
||||
*/
|
||||
export function matchPropertyContent(properties: string[]) {
|
||||
export function matchPropertyContent(properties) {
|
||||
const names = properties.join('|');
|
||||
|
||||
return new RegExp(`((?<=(${names})=")[\\w-]+(?="))|((?<=\\[(${names})]="')[\\w-]+(?='"))`, 'g');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} objectName
|
||||
*/
|
||||
export function matchPropertyAccess(objectName) {
|
||||
return new RegExp(`(?<=${objectName}\\s*\\.\\s*)\\w+`, 'g');
|
||||
}
|
||||
@@ -12,9 +12,7 @@
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
/* eslint-disable unicorn/no-null */
|
||||
|
||||
import {matchPropertyContent, matchTagProperties} from './icon-match';
|
||||
import {matchPropertyAccess, matchPropertyContent, matchTagProperties} from './icon-match.mjs';
|
||||
|
||||
describe('matchTagProperties', function () {
|
||||
const regex = matchTagProperties('test');
|
||||
@@ -59,3 +57,30 @@ describe('matchPropertyContent', function () {
|
||||
expect(`no="content" [no]="'content'"`.match(regex)).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchPropertyAccess', function () {
|
||||
const property = '0_20a_boninAo0_';
|
||||
const object = 'test';
|
||||
const regex = matchPropertyAccess(object);
|
||||
|
||||
it('should match property access', function () {
|
||||
expect(`${object}.${property}`.match(regex)).toEqual([property]);
|
||||
});
|
||||
|
||||
it('should respect whitespace', function () {
|
||||
expect(`${object}. ${property}`.match(regex)).toEqual([property]);
|
||||
expect(`${object} .${property}`.match(regex)).toEqual([property]);
|
||||
expect(`${object} . ${property}`.match(regex)).toEqual([property]);
|
||||
expect(`${object} \n . \n ${property}`.match(regex)).toEqual([property]);
|
||||
});
|
||||
|
||||
it('should not include invalid trailing stuff', function () {
|
||||
expect(`${object}.${property}!`.match(regex)).toEqual([property]);
|
||||
expect(`${object}.${property}.`.match(regex)).toEqual([property]);
|
||||
expect(`${object}.${property}-`.match(regex)).toEqual([property]);
|
||||
expect(`${object}.${property}]`.match(regex)).toEqual([property]);
|
||||
expect(`${object}.${property}}`.match(regex)).toEqual([property]);
|
||||
expect(`${object}.${property};`.match(regex)).toEqual([property]);
|
||||
expect(`${object}.${property}:`.match(regex)).toEqual([property]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,9 +13,8 @@
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A noop function to aid parsing icon names
|
||||
*/
|
||||
export function SCIcon(strings: TemplateStringsArray, ..._keys: string[]): string {
|
||||
return strings.join('');
|
||||
}
|
||||
import {MaterialSymbol} from 'material-symbols';
|
||||
|
||||
export const SCIcon = new Proxy({} as {[key in MaterialSymbol]: key}, {
|
||||
get: (_target, prop: string) => prop as MaterialSymbol,
|
||||
});
|
||||
|
||||
@@ -48,8 +48,8 @@ export class IonBackButtonDirective extends IconReplacer implements OnInit {
|
||||
|
||||
replace() {
|
||||
this.replaceIcon(this.host.querySelector('.button-inner'), {
|
||||
md: SCIcon`arrow_back`,
|
||||
ios: SCIcon`arrow_back_ios`,
|
||||
md: SCIcon.arrow_back,
|
||||
ios: SCIcon.arrow_back_ios,
|
||||
size: 24,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export class IonBreadcrumbDirective extends IconReplacer {
|
||||
this.replaceIcon(
|
||||
this.host.querySelector('span[part="separator"]'),
|
||||
{
|
||||
name: SCIcon`arrow_forward_ios`,
|
||||
name: SCIcon.arrow_forward_ios,
|
||||
size: 16,
|
||||
style: `color: var(--ion-color-tint);`,
|
||||
},
|
||||
@@ -37,7 +37,7 @@ export class IonBreadcrumbDirective extends IconReplacer {
|
||||
this.replaceIcon(
|
||||
this.host.querySelector('button[part="collapsed-indicator"]'),
|
||||
{
|
||||
name: SCIcon`more_horiz`,
|
||||
name: SCIcon.more_horiz,
|
||||
size: 24,
|
||||
},
|
||||
'-collapsed',
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import {IconComponent} from './icon.component';
|
||||
import {IonIcon} from '@ionic/angular';
|
||||
import {MaterialSymbol} from 'material-symbols';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const noop = () => {};
|
||||
@@ -40,7 +41,7 @@ const noopProperty = {
|
||||
selector: 'ion-icon',
|
||||
})
|
||||
export class IonIconDirective implements OnInit, OnDestroy, OnChanges {
|
||||
@Input() name: string;
|
||||
@Input() name: MaterialSymbol;
|
||||
|
||||
@Input() md: string;
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export class IonReorderDirective extends IconReplacer {
|
||||
|
||||
replace() {
|
||||
this.replaceIcon(this.host, {
|
||||
name: SCIcon`reorder`,
|
||||
name: SCIcon.reorder,
|
||||
size: 24,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,11 +26,11 @@ export class IonSearchbarDirective extends IconReplacer {
|
||||
|
||||
replace() {
|
||||
this.replaceIcon(this.host.querySelector('.searchbar-input-container'), {
|
||||
name: SCIcon`search`,
|
||||
name: SCIcon.search,
|
||||
size: 24,
|
||||
});
|
||||
this.replaceIcon(this.host.querySelector('.searchbar-clear-button'), {
|
||||
name: SCIcon`close`,
|
||||
name: SCIcon.close,
|
||||
size: 24,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export const profilePageSections: SCSection[] = [
|
||||
links: [
|
||||
{
|
||||
name: 'Favorites',
|
||||
icon: SCIcon`grade`,
|
||||
icon: SCIcon.grade,
|
||||
link: ['/favorites'],
|
||||
translations: {
|
||||
de: {
|
||||
@@ -73,7 +73,7 @@ export const profilePageSections: SCSection[] = [
|
||||
},
|
||||
{
|
||||
name: 'Schedule',
|
||||
icon: SCIcon`calendar_today`,
|
||||
icon: SCIcon.calendar_today,
|
||||
link: ['/schedule'],
|
||||
translations: {
|
||||
de: {
|
||||
@@ -84,7 +84,7 @@ export const profilePageSections: SCSection[] = [
|
||||
},
|
||||
{
|
||||
name: 'Course Catalog',
|
||||
icon: SCIcon`inventory_2`,
|
||||
icon: SCIcon.inventory_2,
|
||||
link: ['/catalog'],
|
||||
translations: {
|
||||
de: {
|
||||
@@ -95,7 +95,7 @@ export const profilePageSections: SCSection[] = [
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
icon: SCIcon`settings`,
|
||||
icon: SCIcon.settings,
|
||||
link: ['/settings'],
|
||||
translations: {
|
||||
de: {
|
||||
@@ -106,7 +106,7 @@ export const profilePageSections: SCSection[] = [
|
||||
},
|
||||
{
|
||||
name: 'Feedback',
|
||||
icon: SCIcon`rate_review`,
|
||||
icon: SCIcon.rate_review,
|
||||
link: ['/feedback'],
|
||||
translations: {
|
||||
de: {
|
||||
@@ -117,7 +117,7 @@ export const profilePageSections: SCSection[] = [
|
||||
},
|
||||
{
|
||||
name: 'About',
|
||||
icon: SCIcon`info`,
|
||||
icon: SCIcon.info,
|
||||
link: ['/about'],
|
||||
translations: {
|
||||
de: {
|
||||
@@ -140,7 +140,7 @@ export const profilePageSections: SCSection[] = [
|
||||
links: [
|
||||
{
|
||||
name: 'Assessments',
|
||||
icon: SCIcon`fact_check`,
|
||||
icon: SCIcon.fact_check,
|
||||
link: ['/assessments'],
|
||||
needsAuth: true,
|
||||
translations: {
|
||||
@@ -164,7 +164,7 @@ export const profilePageSections: SCSection[] = [
|
||||
links: [
|
||||
{
|
||||
name: 'Library Catalog',
|
||||
icon: SCIcon`local_library`,
|
||||
icon: SCIcon.local_library,
|
||||
link: ['/hebis-search'],
|
||||
translations: {
|
||||
de: {
|
||||
@@ -175,7 +175,7 @@ export const profilePageSections: SCSection[] = [
|
||||
},
|
||||
{
|
||||
name: 'Library Account',
|
||||
icon: SCIcon`badge`,
|
||||
icon: SCIcon.badge,
|
||||
needsAuth: true,
|
||||
link: ['/library-account/profile'],
|
||||
translations: {
|
||||
@@ -187,7 +187,7 @@ export const profilePageSections: SCSection[] = [
|
||||
},
|
||||
{
|
||||
name: 'Orders & Reservations',
|
||||
icon: SCIcon`collections_bookmark`,
|
||||
icon: SCIcon.collections_bookmark,
|
||||
needsAuth: true,
|
||||
link: ['/library-account/holds'],
|
||||
translations: {
|
||||
@@ -199,7 +199,7 @@ export const profilePageSections: SCSection[] = [
|
||||
},
|
||||
{
|
||||
name: 'Checked out items',
|
||||
icon: SCIcon`library_books`,
|
||||
icon: SCIcon.library_books,
|
||||
needsAuth: true,
|
||||
link: ['/library-account/checked-out'],
|
||||
translations: {
|
||||
@@ -211,7 +211,7 @@ export const profilePageSections: SCSection[] = [
|
||||
},
|
||||
{
|
||||
name: 'Fines',
|
||||
icon: SCIcon`request_page`,
|
||||
icon: SCIcon.request_page,
|
||||
needsAuth: true,
|
||||
link: ['/library-account/fines'],
|
||||
translations: {
|
||||
|
||||
Reference in New Issue
Block a user