aboutsummaryrefslogtreecommitdiff
path: root/resources
diff options
context:
space:
mode:
authorJan-Hendrik Willms <tleilax+studip@gmail.com>2025-07-15 12:02:29 +0200
committerJan-Hendrik Willms <tleilax+studip@gmail.com>2025-07-15 12:02:29 +0200
commitc347c0177209f2c94306189df154b8661f1cf086 (patch)
tree2f33be54bf90d252dbd919626f847bd8c895a4bb /resources
parent7801dd9d96c8e8e898af08ea3a760b06bee01d6b (diff)
rewrite icon loader to handle non svg icons as well, fixes #5725
Closes #5725 Merge request studip/studip!4356
Diffstat (limited to 'resources')
-rw-r--r--resources/assets/javascripts/lib/icon-loader.ts52
-rw-r--r--resources/vue/components/StudipIcon.vue171
2 files changed, 136 insertions, 87 deletions
diff --git a/resources/assets/javascripts/lib/icon-loader.ts b/resources/assets/javascripts/lib/icon-loader.ts
index fe47b53..0b95e2f 100644
--- a/resources/assets/javascripts/lib/icon-loader.ts
+++ b/resources/assets/javascripts/lib/icon-loader.ts
@@ -1,14 +1,18 @@
type CacheOption = 'off' | 'session' | 'local';
+type CachedIcon = {
+ isSvg: boolean,
+ content: string
+};
class IconLoader
{
- readonly #cacheKey: string = 'studip/svg-icons';
+ readonly #cacheKey: string = 'studip/icons';
#baseUrl: string;
#useCache: CacheOption = 'off';
#cache: Map<string, string>;
- #promises: Map<string, Promise<string>>;
+ #promises: Map<string, Promise<CachedIcon>>;
constructor(baseUrl: string, useCache: CacheOption = 'off')
{
@@ -16,13 +20,13 @@ class IconLoader
this.#useCache = useCache;
this.#cache = new Map<string, string>(this.#initialState());
- this.#promises = new Map<string, Promise<string>>();
+ this.#promises = new Map<string, Promise<CachedIcon>>();
}
- async load(shape: string): Promise<string>
+ async load(shape: string): Promise<CachedIcon>
{
if (this.#cache.has(shape)) {
- return this.#cache.get(shape)!;
+ return JSON.parse(this.#cache.get(shape)!);
}
if (this.#promises.has(shape)) {
@@ -37,19 +41,37 @@ class IconLoader
try {
const response = await fetch(url);
if (!response.ok) {
- return '';
+ throw new Error(`IconLoader: HTTP ${response.status} ${response.statusText}`);
}
- let svg = await response.text();
- svg = svg.replace(/fill="(?!none)[^"]+"/g, 'fill="currentColor"');
- svg = svg.replace(/(width|height)="[^"]+"/g, '');
+ const icon: CachedIcon = {
+ isSvg: response.headers.get('Content-Type')?.includes('image/svg+xml') ?? false,
+ content: ''
+ };
+
+ if (icon.isSvg) {
+ let svg = await response.text();
+ svg = svg.replace(/fill="(?!none)[^"]+"/g, 'fill="currentColor"');
+ svg = svg.replace(/(width|height)="[^"]+"/g, '');
+ icon.content = svg;
+ } else {
+ const blob = await response.blob();
+ icon.content = await (new Promise(resolve => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.readAsDataURL(blob);
+ }));
+ }
- this.store(shape, svg);
+ this.store(shape, icon);
- return svg;
+ return icon;
} catch(error) {
console.error(`IconLoader: Fehler beim Laden von ${shape}`, error);
- return '';
+ return {
+ isSvg: true,
+ content: ''
+ } as CachedIcon;
} finally {
this.#promises.delete(shape);
}
@@ -60,9 +82,9 @@ class IconLoader
return promise;
}
- store(shape: string, svg: string): void
+ store(shape: string, icon: CachedIcon): void
{
- this.#cache.set(shape, svg);
+ this.#cache.set(shape, JSON.stringify(icon));
this.#getStorage()?.setItem(
this.#cacheKey,
@@ -96,4 +118,4 @@ class IconLoader
const defaultLoader = new IconLoader(window.STUDIP.ASSETS_URL, 'session');
export default defaultLoader;
-export { IconLoader };
+export { IconLoader, CachedIcon };
diff --git a/resources/vue/components/StudipIcon.vue b/resources/vue/components/StudipIcon.vue
index 4e6eb19..1f68c66 100644
--- a/resources/vue/components/StudipIcon.vue
+++ b/resources/vue/components/StudipIcon.vue
@@ -1,84 +1,111 @@
<template>
- <div
- v-if="!name"
- v-bind="$attrs"
- v-html="svgContent"
- :role="ariaRole"
- :class="cssClass"
- :style="computedStyle"
- />
-
- <label v-else class="icon-button undecorated">
- <input type="submit" hidden v-bind="$attrs">
- <div v-html="svgContent" :role="ariaRole" :class="cssClass" :style="computedStyle" />
+ <label v-if="name" class="icon-button undecorated">
+ <input type="submit" hidden v-bind="attrs">
+ <IconContent v-bind="iconAttrs" />
<span v-if="text">{{ text }}</span>
</label>
+
+ <IconContent v-else v-bind="allAttrs" />
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
-import iconLoader from "../../assets/javascripts/lib/icon-loader";
+<script lang="ts" setup>
+import {
+ computed,
+ defineComponent,
+ h,
+ ref,
+ useAttrs,
+ watch,
-export default defineComponent({
- name: 'studip-icon',
- props: {
- ariaRole: { type: String, required: false },
- name: { type: String, required: false },
- role: { type: String, required: false, default: 'clickable' },
- shape: { type: String, required: true },
- size: { type: Number, required: false, default: null },
- inline: { type: Boolean, default: false },
- text: { type: String, required: false }
- },
- data() {
- return { svgContent: '' };
+ CSSProperties,
+ PropType
+} from 'vue';
+import iconLoader, { CachedIcon } from '../../assets/javascripts/lib/icon-loader';
+
+interface IconProps {
+ ariaRole?: string;
+ name?: string;
+ role?: string;
+ shape: string;
+ size?: number | null;
+ inline?: boolean;
+ text?: string;
+}
+
+const props = withDefaults(defineProps<IconProps>(),{
+ role: 'clickable',
+ size: null,
+ inline: false
+});
+
+const isSvg = ref(false);
+const content = ref('');
+
+const attrs = useAttrs();
+const iconAttrs = computed(() => ({
+ ariaRole: props.ariaRole,
+ class: cssClass.value,
+ content: content.value,
+ isSvg: isSvg.value,
+ style: computedStyle.value,
+}));
+const allAttrs = computed(() => ({
+ ...attrs,
+ ...iconAttrs.value
+}));
+
+const cssClass = computed(() => [
+ 'studip-icon',
+ props.inline ? 'studip-icon-inline' : '',
+ `icon-role-${props.role}`,
+ `icon-shape-${shapeName.value}`
+]);
+
+const computedStyle = computed(() => props.size
+ ? { width: `${props.size}px`, height: `${props.size}px` }
+ : {}
+);
+
+const shapeName = computed((): string => {
+ const containsUrl = (shape: string): boolean => /\bhttps?:\/\/\S+/i.test(shape);
+ return containsUrl(props.shape) ? (props.shape.split('/').pop()?.replace('.svg', '') ?? '') : props.shape;
+});
+
+watch(
+ () => props.shape,
+ async (shape) => {
+ const icon: CachedIcon = await iconLoader.load(shape);
+ isSvg.value = icon.isSvg;
+ content.value = icon.content;
},
- computed: {
- color(): string {
- const roleColors: Record<string, string> = {
- accept: 'green',
- attention: 'red',
- clickable: 'blue',
- info: 'black',
- info_alt: 'white',
- inactive: 'grey',
- navigation: 'blue',
- new: 'red',
- sort: 'blue',
- 'status-green': 'green',
- 'status-red': 'red',
- 'status-yellow': 'yellow',
- };
+ { immediate: true }
+);
- return roleColors[this.role] ?? 'blue';
- },
- cssClass(): string[] {
- return [
- 'studip-icon',
- this.inline ? 'studip-icon-inline' : '',
- `icon-role-${this.role}`,
- `icon-shape-${this.shapeName}`
- ];
- },
- computedStyle(): Record<string, string> {
- return this.size
- ? { width: `${this.size}px`, height: `${this.size}px` }
- : {}; // Falls size nicht gesetzt ist, greift CSS mit --icon-size-default
- },
- shapeName(): string {
- const containsUrl = (shape: string): boolean => /\bhttps?:\/\/[^\s]+/i.test(shape);
- return containsUrl(this.shape) ? (this.shape.split('/').pop()?.replace('.svg', '') ?? '') : this.shape;
- }
+const IconContent = defineComponent({
+ props: {
+ isSvg: Boolean,
+ content: String,
+ ariaRole: String,
+ cssClass: String,
+ computedStyle: Object as PropType<CSSProperties>
},
- watch: {
- shape: {
- immediate: true,
- handler(shape) {
- iconLoader.load(shape).then((svg: string) => {
- this.svgContent = svg;
- });
- }
- }
+ setup(props, { attrs }) {
+ const baseAttrs = {
+ class: props.cssClass,
+ role: props.ariaRole,
+ style: props.computedStyle,
+ ...attrs
+ };
+
+ return () => props.isSvg
+ ? h('div', {
+ innerHTML: props.content,
+ ...baseAttrs
+ })
+ : h('img', {
+ src: props.content,
+ ...baseAttrs
+ });
}
});
</script>