fixing small issues in the project
Build and Release to Staging / Build Vision Start (push) Successful in 40s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m8s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 16s

This commit is contained in:
ivanch committed 2026-10-10 23:25:20 -03:00
1 parent 032d2a100a
commit 99dbea0615
17 files changed
+166 -55

No files matched your search

+2 -1
View File
@@ -18,7 +18,8 @@ const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, o
title={edit ? 'Edit Category' : 'Add Category'}
edit={edit}
onClose={onClose}
onSave={() => onSave(name)}
onSave={() => onSave(name.trim())}
canSave={name.trim() !== ''}
onDelete={edit ? onDelete : undefined}
>
<input
+13 -2
View File
@@ -63,6 +63,17 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
closeTimer.current = setTimeout(onClose, 250);
};
const handleCloseRef = useRef(handleClose);
handleCloseRef.current = handleClose;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleCloseRef.current();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
const handleConfigChange = (updates: Partial<Config>) => {
setConfig((prev) => ({ ...prev, ...updates }));
};
@@ -139,7 +150,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
];
return (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-labelledby="configuration-title">
<div
className={`liquid-modal-backdrop fixed inset-0 transition-opacity duration-200 ease-ios ${
isVisible ? 'opacity-100' : 'opacity-0'
@@ -153,7 +164,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
}`}
>
<div className="p-6 sm:p-8 flex-grow overflow-y-auto">
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">Configuration</h2>
<h2 id="configuration-title" className="liquid-title-text text-3xl font-extrabold mb-6">Configuration</h2>
<div className="liquid-surface grid grid-cols-2 sm:grid-cols-4 gap-1 rounded-2xl p-1 mb-7">
{tabs.map((tab) => (
+23 -6
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useId, useRef } from 'react';
interface ModalShellProps {
title: string;
@@ -6,10 +6,23 @@ interface ModalShellProps {
onClose: () => void;
onSave: () => void;
onDelete?: () => void;
canSave?: boolean;
children: React.ReactNode;
}
const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, onDelete, children }) => {
const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, onDelete, canSave = true, children }) => {
const titleId = useId();
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCloseRef.current();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose();
@@ -18,8 +31,8 @@ const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, o
return (
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{title}</h2>
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white" role="dialog" aria-modal="true" aria-labelledby={titleId}>
<h2 id={titleId} className="liquid-title-text text-3xl font-extrabold mb-6">{title}</h2>
<div className="flex flex-col gap-4">{children}</div>
<div className="flex justify-between items-center mt-8">
<div>
@@ -30,7 +43,11 @@ const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, o
)}
</div>
<div className="flex justify-end gap-3">
<button onClick={onSave} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
<button
onClick={onSave}
disabled={!canSave}
className="liquid-button liquid-button-success liquid-focus py-2.5 px-5 disabled:opacity-40 disabled:cursor-not-allowed"
>
Save
</button>
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
@@ -43,4 +60,4 @@ const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, o
);
};
export default ModalShell;
export default ModalShell;
+1 -1
View File
@@ -144,7 +144,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
<div
className="wallpaper-layer wallpaper-transition"
style={{
backgroundImage: `url(${imageUrl})`,
backgroundImage: `url(${JSON.stringify(imageUrl)})`,
filter: `blur(${blur}px) brightness(${brightness / 100})`,
opacity: opacity / 100,
}}
+12 -1
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { Website } from '../types';
import { getWebsiteIcon } from './utils/iconService';
import { normalizeWebsiteUrl } from './utils/urlUtils';
import ModalShell from './ModalShell';
interface WebsiteEditModalProps {
@@ -98,6 +99,15 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
}
}, [iconQuery, iconMetadata]);
const handleSave = () => {
const normalizedUrl = normalizeWebsiteUrl(url);
if (!normalizedUrl) {
alert('Please enter a valid website URL (http, https, ftp, or mailto).');
return;
}
onSave({ id: website?.id, name: name.trim(), url: normalizedUrl, icon });
};
const fetchIcon = async () => {
if (url) {
const fetchedIcon = await getWebsiteIcon(url);
@@ -110,7 +120,8 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
title={edit ? 'Edit Website' : 'Add Website'}
edit={edit}
onClose={onClose}
onSave={() => onSave({ id: website?.id, name, url, icon })}
onSave={handleSave}
canSave={name.trim() !== '' && url.trim() !== ''}
onDelete={edit ? onDelete : undefined}
>
<div className="flex justify-center mb-4">
-1
View File
@@ -40,7 +40,6 @@ const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
onChange={(e) => updateClock({ font: e.target.value as string })}
options={[
{ value: 'Helvetica', label: 'Helvetica' },
{ value: `'Orbitron', sans-serif`, label: 'Orbitron' },
{ value: 'monospace', label: 'Monospace' },
{ value: 'cursive', label: 'Cursive' },
]}
@@ -5,6 +5,7 @@ import { Config, Server } from '../../types';
import RangeSlider from './RangeSlider';
import { TrashIcon } from '../icons';
import { createId } from '../utils/idUtils';
import { parseServerAddress } from '../utils/urlUtils';
interface ServerWidgetTabProps {
config: Config;
@@ -21,6 +22,12 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
const handleAddServer = () => {
if (newServerName.trim() === '' || newServerAddress.trim() === '') return;
try {
parseServerAddress(newServerAddress);
} catch (error) {
alert(error instanceof Error && error.message.startsWith('Server') ? error.message : 'Please enter a valid server address, e.g. 192.168.1.10:8080 or https://example.com.');
return;
}
const newServer: Server = {
id: createId(),
name: newServerName.trim(),
+4 -1
View File
@@ -121,7 +121,10 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
>
<span className="truncate">{wallpaper.name}</span>
<button
onClick={() => onDeleteWallpaper(wallpaper)}
onClick={() => {
if (!wallpaper.url && !confirm(`Delete "${wallpaper.name}"? Uploaded wallpapers are not included in exports and cannot be recovered.`)) return;
void onDeleteWallpaper(wallpaper);
}}
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
aria-label={`Delete ${wallpaper.name}`}
>
+10 -3
View File
@@ -6,7 +6,10 @@ import {
checkChromeStorageLocalAvailable,
removeWallpaperFromChromeStorageLocal,
} from '../utils/StorageLocalManager';
import { getFileNameFromUrl } from '../utils/urlUtils';
import { getFileNameFromUrl, normalizeWebsiteUrl } from '../utils/urlUtils';
import { getWallpaperFrequencyHours } from '../utils/wallpaperUtils';
export const CLOCK_FONTS = ['Helvetica', 'monospace', 'cursive'];
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
@@ -14,7 +17,7 @@ type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
export const DEFAULT_CONFIG: Config = {
title: 'Vision Start',
currentWallpapers: ['Beach'],
wallpaperFrequency: '1d',
wallpaperFrequency: '24h',
wallpaperBlur: 0,
wallpaperBrightness: 108,
wallpaperOpacity: 96,
@@ -78,6 +81,7 @@ const hasStrings = (value: unknown, keys: string[]): value is Record<string, str
export const normalizeConfig = (stored: unknown): Config => {
const config = deepMerge(DEFAULT_CONFIG, stored);
config.currentWallpapers = [...new Set(config.currentWallpapers.filter(name => typeof name === 'string' && name.length > 0))];
config.wallpaperFrequency = `${getWallpaperFrequencyHours(config.wallpaperFrequency)}h`;
config.wallpaperBlur = boundedNumber(config.wallpaperBlur, DEFAULT_CONFIG.wallpaperBlur, 0, 50);
config.wallpaperBrightness = boundedNumber(config.wallpaperBrightness, DEFAULT_CONFIG.wallpaperBrightness, 0, 200);
config.wallpaperOpacity = boundedNumber(config.wallpaperOpacity, DEFAULT_CONFIG.wallpaperOpacity, 1, 100);
@@ -86,6 +90,7 @@ export const normalizeConfig = (stored: unknown): Config => {
config.alignment = choice(config.alignment, ['top', 'middle', 'bottom'], DEFAULT_CONFIG.alignment);
config.horizontalAlignment = choice(config.horizontalAlignment, ['left', 'middle', 'right'], DEFAULT_CONFIG.horizontalAlignment);
config.clock.size = choice(config.clock.size, ['tiny', 'small', 'medium', 'large'], DEFAULT_CONFIG.clock.size);
config.clock.font = choice(config.clock.font, CLOCK_FONTS, DEFAULT_CONFIG.clock.font);
config.clock.format = choice(config.clock.format, ['HH:mm', 'h:mm A'], DEFAULT_CONFIG.clock.format);
config.serverWidget.pingFrequency = boundedNumber(config.serverWidget.pingFrequency, DEFAULT_CONFIG.serverWidget.pingFrequency, 5, 60);
const serverIds = new Set<string>();
@@ -110,8 +115,10 @@ export const normalizeCategories = (stored: unknown): Category[] => {
name: category.name,
websites: websites.flatMap(website => {
if (!hasStrings(website, ['id', 'name', 'url']) || !website.id || websiteIds.has(website.id)) return [];
const url = normalizeWebsiteUrl(website.url);
if (!url) return [];
websiteIds.add(website.id);
return [{ id: website.id, name: website.name, url: website.url, icon: typeof website.icon === 'string' ? website.icon : '', categoryId: category.id }];
return [{ id: website.id, name: website.name, url, icon: typeof website.icon === 'string' ? website.icon : '', categoryId: category.id }];
}),
}];
});
+29 -4
View File
@@ -7,7 +7,7 @@ declare global {
storage?: {
local?: {
set: (items: object, callback?: () => void) => void;
get: (keys: string[] | string, callback: (items: { [key: string]: string }) => void) => void;
get: (keys: string[] | string | null, callback: (items: { [key: string]: string }) => void) => void;
remove: (keys: string | string[], callback?: () => void) => void;
};
};
@@ -94,6 +94,24 @@ export async function removeCachedIconFromChromeStorageLocal(sourceUrl: string):
}
}
export async function pruneCachedIconsFromChromeStorageLocal(keepSourceUrls: string[]): Promise<number> {
if (!checkChromeStorageLocalAvailable()) return 0;
try {
const keep = new Set(keepSourceUrls.map(getIconCacheKey));
const items = await chromeLocalCall<StorageResult>((cb) =>
window.chrome?.storage?.local?.get(null, cb),
);
const stale = Object.keys(items).filter(key => key.startsWith(ICON_CACHE_KEY_PREFIX) && !keep.has(key));
if (stale.length === 0) return 0;
await chromeLocalCall<void>((cb) =>
window.chrome?.storage?.local?.remove(stale, cb),
);
return stale.length;
} catch {
return 0;
}
}
/**
* Adds a new wallpaper to chrome.storage.local.
* File uploads are stored as base64 while remote wallpapers remain URLs.
@@ -123,9 +141,16 @@ export async function addWallpaperToChromeStorageLocal(name: string, url: string
finalName = finalName || getFileNameFromUrl(parsedUrl);
}
await chromeLocalCall<void>((cb) =>
window.chrome?.storage?.local?.set({ [finalName]: url }, cb),
);
try {
await chromeLocalCall<void>((cb) =>
window.chrome?.storage?.local?.set({ [finalName]: url }, cb),
);
} catch (error) {
if (error instanceof Error && /quota/i.test(error.message)) {
throw new Error('Not enough extension storage for this wallpaper. Delete an uploaded wallpaper and try again.');
}
throw error;
}
return finalName;
}
+3 -5
View File
@@ -1,9 +1,7 @@
import { parseServerAddress } from './urlUtils';
async function ping(url: string, multiplier = 1, signal?: AbortSignal): Promise<number> {
const address = url.trim();
const target = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(address) ? address : `http://${address}`);
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
throw new Error('Server addresses must use HTTP or HTTPS.');
}
const target = parseServerAddress(url);
const controller = new AbortController();
const abort = () => controller.abort();
+26 -1
View File
@@ -6,4 +6,29 @@ export const getFileNameFromUrl = (url: URL): string => {
} catch {
return pathName;
}
};
};
const SCHEME_PATTERN = /^[a-z][a-z\d+.-]*:/i;
const AUTHORITY_SCHEME_PATTERN = /^[a-z][a-z\d+.-]*:\/\//i;
const ALLOWED_WEBSITE_PROTOCOLS = new Set(['http:', 'https:', 'ftp:', 'mailto:']);
export const normalizeWebsiteUrl = (raw: string): string | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
const hasScheme = AUTHORITY_SCHEME_PATTERN.test(trimmed) || (SCHEME_PATTERN.test(trimmed) && !/^[^:/]+:\d/.test(trimmed));
try {
const url = new URL(hasScheme ? trimmed : `https://${trimmed}`);
return ALLOWED_WEBSITE_PROTOCOLS.has(url.protocol) ? url.href : null;
} catch {
return null;
}
};
export const parseServerAddress = (address: string): URL => {
const trimmed = address.trim();
const target = new URL(AUTHORITY_SCHEME_PATTERN.test(trimmed) ? trimmed : `http://${trimmed}`);
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
throw new Error('Server addresses must use HTTP or HTTPS.');
}
return target;
};