1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2026-05-13 19:35:07 +00:00

Compare commits

...

11 Commits

Author SHA1 Message Date
孙悟元
3cfd47a011 fix: update MAX_IMAGE_SIZE_MB to 10 for background image settings 2026-04-11 14:05:20 +00:00
孙悟元
91ef84d871 fix: update URL presets for background images 2026-04-11 14:05:00 +00:00
孙悟元
dd68ef7423
Merge pull request #51 from ZeroCatDev/copilot/add-custom-background-image
feat: custom background image with frosted glass effect
2026-04-11 21:59:38 +08:00
copilot-swe-agent[bot]
1f33d6d637
fix: use MAX_IMAGE_SIZE_MB constant consistently in template and error messages
Agent-Logs-Url: https://github.com/ZeroCatDev/Classworks/sessions/6dfae4c0-df49-4612-88b8-9e31287538b0

Co-authored-by: Sunwuyuan <88357633+Sunwuyuan@users.noreply.github.com>
2026-04-11 13:58:41 +00:00
copilot-swe-agent[bot]
c4279acdae
fix: address code review feedback for background feature
- Use SETTINGS_CHANGED_EVENT constant to prevent typos
- Enforce 2MB image size limit (block upload instead of warn)
- Fix resetAll to force re-render SettingItem for enabled toggle
- Optimize watchSettings callback to only reload on relevant key changes

Agent-Logs-Url: https://github.com/ZeroCatDev/Classworks/sessions/6dfae4c0-df49-4612-88b8-9e31287538b0

Co-authored-by: Sunwuyuan <88357633+Sunwuyuan@users.noreply.github.com>
2026-04-11 13:56:41 +00:00
copilot-swe-agent[bot]
3f6e0b88bd
feat: add custom background image with frosted glass effect
- Add background settings (enabled, url, imageData, blur, opacity) to settings.js
- Add watchSettings same-tab reactivity via custom DOM event dispatch
- Create BackgroundSettingsCard.vue with URL input, file upload, sliders, preview
- Apply background layer in App.vue with blur filter and dark overlay
- Add background settings tab to settings.vue navigation

Agent-Logs-Url: https://github.com/ZeroCatDev/Classworks/sessions/6dfae4c0-df49-4612-88b8-9e31287538b0

Co-authored-by: Sunwuyuan <88357633+Sunwuyuan@users.noreply.github.com>
2026-04-11 13:53:32 +00:00
copilot-swe-agent[bot]
f842429f9b
Initial plan 2026-04-11 13:45:10 +00:00
孙悟元
e437f05bdc
Merge pull request #50 from ZeroCatDev/copilot/add-custom-parameters-for-api
Add Hitokoto API category filter support
2026-04-11 21:42:41 +08:00
copilot-swe-agent[bot]
91169b0a03
Remove trailing comma in hitokotoCategories array
Agent-Logs-Url: https://github.com/ZeroCatDev/Classworks/sessions/6de2bd3a-dd64-4f26-a5a5-0f7fed081fdc

Co-authored-by: Sunwuyuan <88357633+Sunwuyuan@users.noreply.github.com>
2026-04-11 13:38:58 +00:00
copilot-swe-agent[bot]
6f697d0fd0
Add Hitokoto API category customization support
Agent-Logs-Url: https://github.com/ZeroCatDev/Classworks/sessions/6de2bd3a-dd64-4f26-a5a5-0f7fed081fdc

Co-authored-by: Sunwuyuan <88357633+Sunwuyuan@users.noreply.github.com>
2026-04-11 13:38:31 +00:00
copilot-swe-agent[bot]
3e2b9e993a
Initial plan 2026-04-11 13:35:55 +00:00
6 changed files with 627 additions and 12 deletions

View File

@ -1,5 +1,11 @@
<template>
<v-app>
<v-app :style="vAppStyle">
<!-- 自定义背景层 -->
<template v-if="bgEnabled">
<div class="app-background-image" :style="bgImageStyle" />
<div class="app-background-overlay" :style="bgOverlayStyle" />
</template>
<!-- 正常路由 -->
<router-view v-slot="{ Component, route }">
<transition mode="out-in" name="md3">
@ -12,24 +18,74 @@
</template>
<script setup>
import { onMounted } from "vue";
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useTheme } from "vuetify";
import { getSetting } from "@/utils/settings";
import { getSetting, watchSettings } from "@/utils/settings";
import RateLimitModal from "@/components/RateLimitModal.vue";
const theme = useTheme();
// Background reactive refs
const bgEnabled = ref(false);
const bgSrc = ref("");
const bgBlur = ref(10);
const bgOpacity = ref(30);
function loadBgSettings() {
bgEnabled.value = getSetting("background.enabled") || false;
const imageData = getSetting("background.imageData") || "";
const url = getSetting("background.url") || "";
bgSrc.value = imageData || url;
bgBlur.value = getSetting("background.blur") ?? 10;
bgOpacity.value = getSetting("background.opacity") ?? 30;
}
const vAppStyle = computed(() => {
if (!bgEnabled.value || !bgSrc.value) return {};
return { background: "transparent" };
});
const bgImageStyle = computed(() => ({
backgroundImage: `url(${bgSrc.value})`,
backgroundSize: "cover",
backgroundPosition: "center",
filter: `blur(${bgBlur.value}px)`,
// Scale slightly to hide blur edge artifacts
transform: "scale(1.05)",
}));
const bgOverlayStyle = computed(() => ({
background: `rgba(0, 0, 0, ${bgOpacity.value / 100})`,
}));
let unwatchSettings = null;
onMounted(() => {
//
const savedTheme = getSetting("theme.mode");
theme.global.name.value = savedTheme;
loadBgSettings();
unwatchSettings = watchSettings((_, event) => {
// If event detail is available (same-tab change), only reload on background keys
const changedKey = event?.detail?.key;
if (!changedKey || changedKey.startsWith("background.") || changedKey === "theme.mode") {
loadBgSettings();
theme.global.name.value = getSetting("theme.mode");
}
});
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
window.deferredPwaPrompt = e;
window.dispatchEvent(new Event('pwa-prompt-ready'));
});
});
onUnmounted(() => {
if (unwatchSettings) unwatchSettings();
});
</script>
<style>
/* 全局样式(从 index.vue 迁移,确保全局可用且仅加载一次) */
@ -52,4 +108,21 @@ onMounted(() => {
opacity: 0;
transform: translateX(-0.5vw);
}
/* 自定义背景层 */
.app-background-image,
.app-background-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
pointer-events: none;
}
.app-background-image {
transform-origin: center center;
will-change: transform, filter;
}
</style>

View File

@ -61,7 +61,8 @@ export default {
refreshInterval: 60,
kvConfig: {
sources: ['zhaoyu'],
sensitiveWords: []
sensitiveWords: [],
hitokotoCategories: []
},
sentence: '',
author: '',
@ -125,7 +126,8 @@ export default {
this.kvConfig = {
sources: Array.isArray(data.sources) && data.sources.length > 0 ? data.sources : ['zhaoyu'],
sensitiveWords: data.sensitiveWords ? data.sensitiveWords.split(/[,]/).map(w => w.trim()).filter(w => w) : [],
jinrishiciToken: data.jinrishiciToken
jinrishiciToken: data.jinrishiciToken,
hitokotoCategories: Array.isArray(data.hitokotoCategories) ? data.hitokotoCategories : []
}
}
} catch (e) {
@ -155,7 +157,13 @@ export default {
let origin = ''
if (source === 'hitokoto') {
const res = await axios.get('https://v1.hitokoto.cn/')
const params = new URLSearchParams()
const categories = this.kvConfig.hitokotoCategories
if (Array.isArray(categories) && categories.length > 0) {
categories.forEach(cat => params.append('c', cat))
}
const url = 'https://v1.hitokoto.cn/' + (params.toString() ? '?' + params.toString() : '')
const res = await axios.get(url)
data = res.data
content = data.hitokoto
author = data.from_who

View File

@ -47,6 +47,25 @@
</div>
</v-list-item>
<v-list-item v-if="kvConfig.sources.includes('hitokoto')">
<v-list-item-title class="mb-2">一言句子类型</v-list-item-title>
<div class="text-caption text-grey mb-2">不选则返回所有类型可多选</div>
<div class="d-flex flex-wrap gap-2">
<v-checkbox
v-for="cat in hitokotoCategories"
:key="cat.value"
v-model="kvConfig.hitokotoCategories"
:label="cat.label"
:value="cat.value"
hide-details
density="compact"
class="mr-4"
:disabled="loading"
@update:model-value="saveKvSettings"
/>
</div>
</v-list-item>
<v-list-item v-if="kvConfig.sources.includes('jinrishici')">
<v-text-field
v-model="kvConfig.jinrishiciToken"
@ -278,8 +297,23 @@ export default {
kvConfig: {
sources: ['zhaoyu'],
sensitiveWords: '',
jinrishiciToken: null
jinrishiciToken: null,
hitokotoCategories: []
},
hitokotoCategories: [
{ value: 'a', label: '动画' },
{ value: 'b', label: '漫画' },
{ value: 'c', label: '游戏' },
{ value: 'd', label: '文学' },
{ value: 'e', label: '原创' },
{ value: 'f', label: '来自网络' },
{ value: 'g', label: '其他' },
{ value: 'h', label: '影视' },
{ value: 'i', label: '诗词' },
{ value: 'j', label: '网易云' },
{ value: 'k', label: '哲学' },
{ value: 'l', label: '抖机灵' }
],
loading: false,
testLoading: false,
testMessage: '',
@ -306,7 +340,8 @@ export default {
this.kvConfig = {
sources: Array.isArray(data.sources) ? data.sources : ['zhaoyu'],
sensitiveWords: data.sensitiveWords || '',
jinrishiciToken: data.jinrishiciToken
jinrishiciToken: data.jinrishiciToken,
hitokotoCategories: Array.isArray(data.hitokotoCategories) ? data.hitokotoCategories : []
}
}
} catch (e) {

View File

@ -0,0 +1,428 @@
<template>
<settings-card border icon="mdi-image" title="背景设置">
<v-list>
<setting-item :key="settingItemKey" :setting-key="'background.enabled'" />
</v-list>
<v-divider class="mb-4" />
<div class="px-4 pb-4">
<!-- 预览区域 -->
<div class="preview-area mb-6" :style="previewContainerStyle">
<div class="preview-bg" :style="previewBgStyle" />
<div class="preview-overlay" :style="previewOverlayStyle" />
<div class="preview-text">背景预览</div>
</div>
<!-- 图片来源 -->
<div class="d-flex align-center mb-4">
<v-icon class="mr-2" color="primary">mdi-image-search</v-icon>
<span class="text-subtitle-1 font-weight-bold">图片来源</span>
</div>
<!-- 来源选择 -->
<v-btn-toggle
v-model="imageSource"
color="primary"
density="comfortable"
class="mb-4"
mandatory
rounded="xl"
>
<v-btn value="url" prepend-icon="mdi-link-variant">网络地址</v-btn>
<v-btn value="upload" prepend-icon="mdi-upload">本地上传</v-btn>
</v-btn-toggle>
<!-- URL 输入 -->
<div v-if="imageSource === 'url'" class="mb-4">
<v-text-field
v-model="localUrl"
label="图片地址"
placeholder="https://example.com/background.jpg"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-link"
clearable
hide-details="auto"
:rules="[validateUrl]"
@update:model-value="onUrlChange"
/>
<div class="d-flex flex-wrap gap-2 mt-2">
<v-chip
v-for="preset in urlPresets"
:key="preset.label"
size="small"
variant="tonal"
color="primary"
class="cursor-pointer"
@click="applyPreset(preset.url)"
>
{{ preset.label }}
</v-chip>
</div>
</div>
<!-- 本地上传 -->
<div v-if="imageSource === 'upload'" class="mb-4">
<div
class="upload-area rounded-xl pa-6 text-center mb-3"
:class="{ 'upload-hover': isDragging }"
@dragover.prevent="isDragging = true"
@dragleave="isDragging = false"
@drop.prevent="handleDrop"
@click="triggerFileInput"
>
<v-icon size="40" color="primary" class="mb-2">mdi-image-plus</v-icon>
<div class="text-body-2">点击或拖拽图片到此处上传</div>
<div class="text-caption text-medium-emphasis mt-1">支持 JPGPNGWebPGIF建议小于 {{ maxImageSizeMB }}MB</div>
<input
ref="fileInput"
type="file"
accept="image/*"
style="display: none"
@change="handleFileChange"
/>
</div>
<v-alert
v-if="uploadWarning"
type="warning"
variant="tonal"
density="compact"
class="mb-2"
icon="mdi-alert"
>
{{ uploadWarning }}
</v-alert>
<div v-if="localImageData" class="d-flex align-center ga-2">
<v-chip color="success" prepend-icon="mdi-check-circle" size="small">
已上传本地图片
</v-chip>
<v-btn
size="small"
variant="text"
color="error"
prepend-icon="mdi-delete"
@click="clearUploadedImage"
>
清除
</v-btn>
</div>
</div>
<v-divider class="my-5" />
<!-- 毛玻璃效果设置 -->
<div class="d-flex align-center mb-4">
<v-icon class="mr-2" color="blue">mdi-blur</v-icon>
<span class="text-subtitle-1 font-weight-bold">毛玻璃效果</span>
</div>
<div class="mb-4">
<div class="d-flex justify-space-between align-center mb-1">
<span class="text-body-2 text-medium-emphasis">模糊幅度</span>
<span class="text-body-2 font-weight-bold">{{ localBlur }}px</span>
</div>
<v-slider
v-model="localBlur"
:min="0"
:max="50"
:step="1"
color="primary"
track-color="grey-lighten-3"
thumb-label
hide-details
@update:model-value="onBlurChange"
>
<template #prepend>
<v-icon size="small" color="grey">mdi-blur-off</v-icon>
</template>
<template #append>
<v-icon size="small" color="primary">mdi-blur</v-icon>
</template>
</v-slider>
</div>
<div class="mb-4">
<div class="d-flex justify-space-between align-center mb-1">
<span class="text-body-2 text-medium-emphasis">遮罩暗色程度</span>
<span class="text-body-2 font-weight-bold">{{ localOpacity }}%</span>
</div>
<v-slider
v-model="localOpacity"
:min="0"
:max="80"
:step="1"
color="blue-grey"
track-color="grey-lighten-3"
thumb-label
hide-details
@update:model-value="onOpacityChange"
>
<template #prepend>
<v-icon size="small" color="grey">mdi-brightness-7</v-icon>
</template>
<template #append>
<v-icon size="small" color="blue-grey">mdi-brightness-2</v-icon>
</template>
</v-slider>
</div>
<v-divider class="my-5" />
<!-- 保存按钮 -->
<div class="d-flex justify-end ga-3">
<v-btn variant="text" prepend-icon="mdi-restore" @click="resetAll">
重置
</v-btn>
<v-btn
color="primary"
variant="elevated"
prepend-icon="mdi-content-save"
:loading="saving"
@click="saveAll"
>
保存设置
</v-btn>
</div>
</div>
</settings-card>
</template>
<script>
import SettingsCard from '@/components/SettingsCard.vue';
import SettingItem from '@/components/settings/SettingItem.vue';
import { getSetting, setSetting, resetSetting } from '@/utils/settings';
const URL_PRESETS = [
{ label: 'Bing 随机壁纸', url: 'https://bing.img.run/rand.php' },
{ label: 'Bing 每日壁纸', url: 'https://bing.img.run/1920x1080.php' },
{ label: '随机风景', url: 'https://picsum.photos/1920/1080?random=1' },
{ label: '随机二次元', url: 'https://uapis.cn/api/v1/random/image?category=acg&type=pc' },
];
const MAX_IMAGE_SIZE_MB = 10;
export default {
name: 'BackgroundSettingsCard',
components: { SettingsCard, SettingItem },
data() {
const imageData = getSetting('background.imageData') || '';
const url = getSetting('background.url') || '';
return {
imageSource: imageData ? 'upload' : 'url',
localUrl: url,
localImageData: imageData,
localBlur: getSetting('background.blur') ?? 10,
localOpacity: getSetting('background.opacity') ?? 30,
isDragging: false,
saving: false,
uploadWarning: '',
urlPresets: URL_PRESETS,
settingItemKey: 0,
maxImageSizeMB: MAX_IMAGE_SIZE_MB,
};
},
computed: {
/** The active image src for preview */
activeImageSrc() {
if (this.imageSource === 'upload' && this.localImageData) {
return this.localImageData;
}
if (this.imageSource === 'url' && this.localUrl) {
return this.localUrl;
}
return '';
},
previewContainerStyle() {
return {
position: 'relative',
width: '100%',
height: '160px',
borderRadius: '12px',
overflow: 'hidden',
border: '1px solid rgba(128,128,128,0.3)',
};
},
previewBgStyle() {
if (!this.activeImageSrc) {
return {
position: 'absolute',
inset: '0',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
filter: `blur(${this.localBlur}px)`,
transform: 'scale(1.1)',
};
}
return {
position: 'absolute',
inset: '0',
backgroundImage: `url(${this.activeImageSrc})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
filter: `blur(${this.localBlur}px)`,
transform: 'scale(1.1)',
};
},
previewOverlayStyle() {
return {
position: 'absolute',
inset: '0',
background: `rgba(0, 0, 0, ${this.localOpacity / 100})`,
};
},
},
methods: {
validateUrl(val) {
if (!val) return true;
try {
new URL(val);
return true;
} catch {
return '请输入有效的图片地址';
}
},
onUrlChange(val) {
this.localUrl = val || '';
},
onBlurChange(val) {
this.localBlur = val;
},
onOpacityChange(val) {
this.localOpacity = val;
},
applyPreset(url) {
this.localUrl = url;
this.imageSource = 'url';
},
triggerFileInput() {
this.$refs.fileInput.click();
},
handleDrop(event) {
this.isDragging = false;
const file = event.dataTransfer?.files?.[0];
if (file) this.processFile(file);
},
handleFileChange(event) {
const file = event.target.files?.[0];
if (file) this.processFile(file);
// Reset input so same file can be re-selected
event.target.value = '';
},
processFile(file) {
this.uploadWarning = '';
if (!file.type.startsWith('image/')) {
this.uploadWarning = '请选择图片文件';
return;
}
const sizeMB = file.size / 1024 / 1024;
if (sizeMB > MAX_IMAGE_SIZE_MB) {
this.uploadWarning = `图片大小为 ${sizeMB.toFixed(1)}MB超过 ${MAX_IMAGE_SIZE_MB}MB 限制,请压缩后重试`;
return;
}
const reader = new FileReader();
reader.onload = (e) => {
this.localImageData = e.target.result;
};
reader.readAsDataURL(file);
},
clearUploadedImage() {
this.localImageData = '';
this.uploadWarning = '';
},
async saveAll() {
this.saving = true;
try {
// Determine which image source to persist
if (this.imageSource === 'upload') {
setSetting('background.imageData', this.localImageData || '');
setSetting('background.url', '');
} else {
setSetting('background.url', this.localUrl || '');
setSetting('background.imageData', '');
}
setSetting('background.blur', this.localBlur);
setSetting('background.opacity', this.localOpacity);
} finally {
this.saving = false;
}
},
resetAll() {
resetSetting('background.enabled');
resetSetting('background.url');
resetSetting('background.imageData');
resetSetting('background.blur');
resetSetting('background.opacity');
this.localUrl = getSetting('background.url') || '';
this.localImageData = getSetting('background.imageData') || '';
this.localBlur = getSetting('background.blur') ?? 10;
this.localOpacity = getSetting('background.opacity') ?? 30;
this.imageSource = 'url';
this.uploadWarning = '';
// Force re-render of SettingItem to reflect reset enabled state
this.settingItemKey++;
},
},
};
</script>
<style scoped>
.preview-area {
transition: all 0.3s ease;
}
.preview-text {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.1rem;
font-weight: 600;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
z-index: 1;
pointer-events: none;
}
.upload-area {
border: 2px dashed rgba(128, 128, 128, 0.4);
cursor: pointer;
transition: all 0.2s ease;
background: rgba(128, 128, 128, 0.05);
}
.upload-area:hover,
.upload-hover {
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.05);
}
.gap-2 {
gap: 8px;
}
</style>

View File

@ -195,6 +195,10 @@
<homework-template-card border/>
</v-tabs-window-item>
<v-tabs-window-item value="background">
<background-settings-card border />
</v-tabs-window-item>
<v-tabs-window-item value="developer"
>
<settings-card border icon="mdi-developer-board" title="开发者选项">
@ -280,6 +284,7 @@ import KvDatabaseCard from "@/components/settings/cards/KvDatabaseCard.vue";
import HitokotoSettings from "@/components/HitokotoSettings.vue";
import NotificationSoundSettings from "@/components/settings/NotificationSoundSettings.vue";
import NoiseSettingsCard from "@/components/settings/cards/NoiseSettingsCard.vue";
import BackgroundSettingsCard from "@/components/settings/cards/BackgroundSettingsCard.vue";
export default {
name: "Settings",
@ -304,6 +309,7 @@ export default {
HitokotoSettings,
NotificationSoundSettings,
NoiseSettingsCard,
BackgroundSettingsCard,
},
setup() {
const {mobile} = useDisplay();
@ -442,6 +448,12 @@ export default {
value: "randomPicker",
},
{
title: "背景",
icon: "mdi-image",
value: "background",
},
{
title: "开发者",
icon: "mdi-developer-board",

View File

@ -62,6 +62,9 @@ async function initializeStorage() {
// 存储所有设置的localStorage键名
const SETTINGS_STORAGE_KEY = "Classworks_settings";
// 同标签页设置变化事件名
const SETTINGS_CHANGED_EVENT = "classworks:settings:changed";
// 新增: Classworks云端存储的默认设置
const classworksCloudDefaults = {
@ -441,6 +444,40 @@ const settingsDefinitions = {
// 设置应用的主题模式,可选亮色或暗色主题
},
// 背景设置
"background.enabled": {
type: "boolean",
default: false,
description: "启用自定义背景",
icon: "mdi-image",
},
"background.url": {
type: "string",
default: "",
description: "背景图片地址",
icon: "mdi-link",
},
"background.imageData": {
type: "string",
default: "",
description: "本地背景图片Base64",
icon: "mdi-image-area",
},
"background.blur": {
type: "number",
default: 10,
validate: (value) => value >= 0 && value <= 50,
description: "毛玻璃模糊幅度px",
icon: "mdi-blur",
},
"background.opacity": {
type: "number",
default: 30,
validate: (value) => value >= 0 && value <= 80,
description: "遮罩暗色程度(%",
icon: "mdi-circle-half-full",
},
// 通知铃声设置
"notification.singleSound": {
type: "string",
@ -674,6 +711,13 @@ class SettingsManagerClass {
this.saveSettings();
this.logSettingsChange(key, oldValue, value);
// 触发同标签页内的设置变化事件
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(SETTINGS_CHANGED_EVENT, {
detail: { key, value },
}));
}
// 为了保持向后兼容同时更新旧的localStorage键
const legacyKey = definition.legacyKey;
if (legacyKey && typeof localStorage !== "undefined") {
@ -721,6 +765,13 @@ class SettingsManagerClass {
this.settingsCache[key] = definition.default;
this.saveSettings();
// 触发同标签页内的设置变化事件
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(SETTINGS_CHANGED_EVENT, {
detail: { key, value: definition.default },
}));
}
}
/**
@ -743,15 +794,23 @@ class SettingsManagerClass {
if (typeof window === "undefined") return () => {
};
const handler = (event) => {
const storageHandler = (event) => {
if (event.key === SETTINGS_STORAGE_KEY) {
this.settingsCache = JSON.parse(event.newValue);
callback(this.settingsCache);
callback(this.settingsCache, null);
}
};
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
const customHandler = (event) => {
callback(this.settingsCache, event);
};
window.addEventListener("storage", storageHandler);
window.addEventListener(SETTINGS_CHANGED_EVENT, customHandler);
return () => {
window.removeEventListener("storage", storageHandler);
window.removeEventListener(SETTINGS_CHANGED_EVENT, customHandler);
};
}
/**