This commit is contained in:
Your Name
2025-04-18 09:52:27 -04:00
parent f569bb2240
commit 066bb11814
54 changed files with 1714 additions and 469 deletions
+14
View File
@@ -0,0 +1,14 @@
import React, { useEffect } from 'react';
import { NewTab } from './components/NewTab';
import { useAtom } from 'jotai';
import { backgroundImageState } from './state/backgroundImage.state';
import { IpcService } from './services/ipcService';
export const App: React.FC = () => {
const [backgroundImage] = useAtom(backgroundImageState)
useEffect(() => { document.body.style.backgroundImage = `url(${backgroundImage})` }, [backgroundImage]);
IpcService.writeFile({ path: 'testpath', fileContent: 'testcontent' }).then(console.log)
return <NewTab />;
};
+36
View File
@@ -0,0 +1,36 @@
import React, { Suspense } from 'react';
import { useAtom } from 'jotai'
import { WidgetFactory } from './WidgetFactory';
import { gridState } from '../state/grid.state';
import { widgetState } from '../state/widget.state';
import { GlobalSettingsPanel } from './global-settings/GlobalSettingsPanel';
import { isIncognitoState } from '../state/incogntio.state';
import { GridOverlay } from './grid-overlay/GridOverlay';
export const NewTab: React.FC = () => {
const [widgets] = useAtom(widgetState)
const [grid] = useAtom(gridState)
const [isIncognitoOn] = useAtom(isIncognitoState)
const widgetsToShow = widgets.filter(widget =>
widget.showIn === 'both' ||
(!isIncognitoOn && widget.showIn === 'standard') ||
(isIncognitoOn && widget.showIn === 'incognito')
)
return (
<Suspense fallback={<></>}>
<GlobalSettingsPanel />
<GridOverlay grid={grid} />
{widgetsToShow.map(widget => (
<WidgetFactory
key={widget.id}
widget={widget}
grid={grid}
/>
))}
</Suspense>
);
};
@@ -1,12 +1,11 @@
import React, { useRef, useState, useEffect, ReactElement, useCallback, startTransition } from 'react';
import { WidgetData } from '../types';
import { CogwheelIcon } from './CogwheelIcon';
import { CogwheelIcon } from './components/CogwheelIcon';
import { useAtomValue } from 'jotai';
import { editState } from '../state/edit.state';
import { CloseButton } from './Closebutton';
import { ModalOverlay } from './ModalOverlay';
import { SettingsComponent } from './SettingsComponent';
import { debounce, debounceTime } from 'rxjs';
import { CloseButton } from './components/Closebutton';
import { ModalOverlay } from './components/ModalOverlay';
import { SettingsComponent } from './components/SettingsComponent';
interface WidgetProps {
widgetData: WidgetData;
@@ -15,9 +14,11 @@ interface WidgetProps {
settingsContent: ReactElement;
updateWidget: (id: string, data: Partial<WidgetData>) => void;
removeWidget: (id: string) => void;
disableRemove?: boolean;
disableEdit?: boolean;
}
export const Widget: React.FC<WidgetProps> = ({ widgetData, gridSize, children, settingsContent, updateWidget, removeWidget }) => {
export const Widget: React.FC<WidgetProps> = ({ widgetData, gridSize, children, settingsContent, updateWidget, removeWidget, disableEdit, disableRemove }) => {
const widgetRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
@@ -61,11 +62,11 @@ export const Widget: React.FC<WidgetProps> = ({ widgetData, gridSize, children,
const rect = widgetRef.current.getBoundingClientRect();
const gridX = snapToGrid(rect.left, gridSize.width);
const gridY = snapToGrid(rect.top, gridSize.height);
if(gridX !== widgetData.gridX || gridY !== widgetData.gridY){
if (gridX !== widgetData.y || gridY !== widgetData.x) {
// overwrite the values so prevent a visual "bounce"
widgetData.gridX = gridX;
widgetData.gridY = gridY;
updateWidget(widgetData.id, { gridX, gridY });
widgetData.y = gridX;
widgetData.x = gridY;
updateWidget(widgetData.id, { y: gridX, x: gridY });
}
}
};
@@ -100,10 +101,10 @@ export const Widget: React.FC<WidgetProps> = ({ widgetData, gridSize, children,
useEffect(() => {
if (widgetRef.current && !isDragging) {
widgetRef.current.style.left = `${widgetData.gridX * gridSize.width}px`;
widgetRef.current.style.top = `${widgetData.gridY * gridSize.height}px`;
widgetRef.current.style.width = `${(widgetData.gridWidth) * gridSize.width}px`;
widgetRef.current.style.height = `${(widgetData.gridHeight) * gridSize.height}px`;
widgetRef.current.style.left = `${widgetData.y * gridSize.width}px`;
widgetRef.current.style.top = `${widgetData.x * gridSize.height}px`;
widgetRef.current.style.width = `${(widgetData.width) * gridSize.width}px`;
widgetRef.current.style.height = `${(widgetData.height) * gridSize.height}px`;
}
}, [widgetData, gridSize, isDragging]);
@@ -115,25 +116,28 @@ export const Widget: React.FC<WidgetProps> = ({ widgetData, gridSize, children,
<SettingsComponent
widgetData={widgetData}
updateWidget={updateWidget}
settingsOpen={[isSettingsOpen, setSettingsOpen]}
>
{settingsContent}
</SettingsComponent>
</ModalOverlay>
<div
ref={widgetRef}
className={`container ${widgetData.background ? 'aero' : ''}`}
className={`container ${widgetData.type} ${widgetData.background ? 'aero' : ''}`}
onMouseDown={handleMouseDown}
style={{ cursor: isEditable && !isSettingsOpen ? 'grab' : 'default' }}
>
{isEditable && <>
{(isEditable && !widgetData.permanent) && <>
<CloseButton onClick={() => removeWidget(widgetData.id)} />
</>}
{(isEditable) &&
<button
className="settings-button"
onClick={() => setSettingsOpen(true)}
>
<CogwheelIcon size={24} />
</button>
</>}
}
{children}
</div>
</>);
+72
View File
@@ -0,0 +1,72 @@
import React, { useCallback, useEffect, useState } from 'react';
import { GlobalSettings, WidgetData, WidgetExport, WidgetTypeValues } from '../types';
import { Widget } from './Widget';
import { SearchWidget } from './search-widget/SearchWidget';
import { ClockWidget } from './clock-widget/ClockWidget';
import { GlobalSettingsWidget } from './global-settings/GlobalSettingsWidget';
import { useAtom } from 'jotai';
import { widgetState } from '../state/widget.state';
import { settingsService } from '../services/settingsService';
export interface WidgetFactoryProps {
widget: WidgetData;
grid: GlobalSettings['grid'];
}
const WidgetMap: { [key in WidgetTypeValues]: WidgetExport } = {
'clock': ClockWidget,
'search': SearchWidget,
'globalsettings': GlobalSettingsWidget
}
export const WidgetFactory: React.FC<WidgetFactoryProps> = ({ widget, grid }) => {
const [widgets, setWidgets] = useAtom(widgetState)
const [gridSize, setGridSize] = useState({ width: window.innerWidth / grid.cols, height: window.innerHeight / grid.rows });
useEffect(() => {
const handleResize = () => {
setGridSize({
width: window.innerWidth / grid.cols,
height: window.innerHeight / grid.rows
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [window.innerWidth, window.innerHeight, grid])
const updateWidgets = useCallback(
<T extends WidgetData>(id: string, updates: Partial<T>) => {
console.log(id, updates)
const updatedWidgets = widgets.map(w =>
w.id === id ? { ...w, ...updates } : w
);
setWidgets(updatedWidgets)
},
[widgets, setWidgets, settingsService]
);
const removeWidget = useCallback(
(widgetId: string) => {
const updatedWidgets = widgets.filter(widget => widget.id !== widgetId)
setWidgets(updatedWidgets)
},
[widgets, setWidgets, settingsService]
);
const Wdgt = WidgetMap[widget.type]
const component = <Wdgt.widget widget={widget} updateWidget={updateWidgets}></Wdgt.widget>
const settings = Wdgt.settings({ widget, updateWidget: updateWidgets })
return (
<Widget
widgetData={widget}
gridSize={gridSize}
settingsContent={settings}
updateWidget={updateWidgets}
removeWidget={removeWidget}
>
{component}
</Widget>
);
};
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { ClockWidgetData, WidgetExport } from '../types';
import { ClockWidgetData, WidgetExport } from '../../types';
interface ClockWidgetProps {
widget: ClockWidgetData;
@@ -28,8 +28,8 @@ export const ClockWidgetComponent: React.FC<ClockWidgetProps> = ({ widget, updat
};
return <>
<div className="clock-time">{formatTime()}</div>
<div className="timezone-display">{widget.timezone}</div>
<div className="time">{formatTime()}</div>
<div className="timezone">{widget.timezone}</div>
</>;
};
@@ -1,10 +1,12 @@
type CogwheelIconProps = {
size: number;
onClick?: () => void;
className?: any
}
export function CogwheelIcon({size}: CogwheelIconProps) {
export function CogwheelIcon({size, className, onClick}: CogwheelIconProps) {
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg className={className} onClick={onClick} width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l-.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l-.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v-.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l-.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
@@ -0,0 +1,69 @@
import { Dispatch, ReactElement } from "react"
import { WidgetData } from "../../types"
import { SetStateAction, useAtom } from "jotai"
import { incognitoAllowedState, isIncognitoState } from "../../state/incogntio.state"
type GenericSettingsProps = {
widgetData: WidgetData
updateWidget: (id: string, data: Partial<WidgetData>) => void
children: ReactElement | ReactElement[]
settingsOpen?: [boolean, Dispatch<SetStateAction<boolean>>]
}
export function SettingsComponent({
widgetData,
updateWidget,
children
}: GenericSettingsProps) {
const [isIncognitoAllowed] = useAtom(incognitoAllowedState)
return <>
{children}
<label>
Width (grid cells):
<input
type="number"
min="1"
value={widgetData.width}
onChange={e => updateWidget(widgetData.id, { width: parseInt(e.target.value) })}
/>
</label>
<label>
Height (grid cells):
<input
type="number"
min="1"
value={widgetData.height}
onChange={e => updateWidget(widgetData.id, { height: parseInt(e.target.value) })}
/>
</label>
<label>
Background:
<input
type="checkbox"
checked={widgetData.background}
onChange={_ => updateWidget(widgetData.id, { background: !widgetData.background })}
/>
</label>
{isIncognitoAllowed &&
<label>
Show in standard or incognito tabs?
<select
value={widgetData.showIn}
onChange={e => updateWidget(
widgetData.id,
{ showIn: e.target.value as WidgetData['showIn'] }
)}>
{(['standard', 'incognito', 'both'] as Array<WidgetData['showIn']>)
.map(showInOption => (
<option key={showInOption} value={showInOption}>
{showInOption}
</option>
))
}
</select>
</label>
}
</>
}
@@ -0,0 +1,73 @@
import { useAtom } from "jotai";
import { ModalOverlay } from "../components/ModalOverlay";
import { useState } from "react";
import { globalSettingsOpenState } from "../../state/globalSettingsOpen.state";
import { Box, Tab, Tabs, Typography } from "@mui/material";
import { UtabSettings } from "./tab-contents/UtabSettings";
import { BackgroundSettings } from "./tab-contents/BackgroundSettings";
import { SearchEngineSettings } from "./tab-contents/SearchEngineSettings";
const settingsComponents = {
'uTab': UtabSettings,
'Background': BackgroundSettings,
'Search': SearchEngineSettings
}
export function GlobalSettingsPanel() {
const [isOpen, setOpen] = useAtom(globalSettingsOpenState)
const [tab, setTab] = useState(0);
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
setTab(newValue);
};
return (
<ModalOverlay visible={isOpen} setVisible={setOpen}>
<Box sx={{ flexGrow: 1, display: 'flex', height: 600 }}>
<Tabs
orientation="vertical"
variant="scrollable"
value={tab}
onChange={handleChange}
aria-label="Vertical tabs example"
sx={{ borderRight: 1, borderColor: 'divider', textAlign: 'end' }}
>
{Object.keys(settingsComponents).map(key => <Tab label={key} />)}
</Tabs>
{Object.values(settingsComponents).map(
(Component, i) =>
<TabPanel value={tab} index={i}>
<Component></Component>
</TabPanel>
)}
</Box>
</ModalOverlay>
)
}
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`vertical-tabpanel-${index}`}
aria-labelledby={`vertical-tab-${index}`}
{...other}
>
{value === index && (
<Box sx={{ p: 3 }}>
<Typography>{children}</Typography>
</Box>
)}
</div>
);
}
@@ -0,0 +1,22 @@
import React from 'react';
import { CogwheelIcon } from '../components/CogwheelIcon';
import { useAtom } from 'jotai';
import { WidgetData, WidgetExport } from '../../types';
import { globalSettingsOpenState } from '../../state/globalSettingsOpen.state';
interface GlobalSettingsProps {
addWidget: (widget: WidgetData) => void,
}
const GlobalSettingsComponent: React.FC<GlobalSettingsProps> = ({ addWidget }) => {
const [isGlobalSettingsOpen, setGlobalSettingsOpen] = useAtom(globalSettingsOpenState)
return (
<CogwheelIcon className="settings-toggle" size={24} onClick={() => setGlobalSettingsOpen(!isGlobalSettingsOpen)} />
);
};
export const GlobalSettingsWidget: WidgetExport = {
settings: () => <></>,
widget: GlobalSettingsComponent
}
@@ -0,0 +1,17 @@
import { TextField } from "@mui/material";
import { useAtom } from "jotai";
import { backgroundImageState } from "../../../state/backgroundImage.state";
export function BackgroundSettings() {
const [backgroundImage, setBackgroundImage] = useAtom(backgroundImageState)
return (<>
<TextField
type="text"
value={backgroundImage}
onChange={(e) => setBackgroundImage(e.target.value)}
placeholder="Enter image URL"
/>
</>)
}
@@ -0,0 +1,83 @@
import { Box, Button, IconButton, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
import { useAtom } from "jotai";
import { availableEnginesState } from "../../../state/searchEngines.state";
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import TextField from '@mui/material/TextField';
import { useCallback, useState } from "react";
export function SearchEngineSettings() {
const [availableSearchEngines, setAvailableSearchEngines] = useAtom(availableEnginesState)
const [aliasInput, setAliasInput] = useState('')
const [searchStringInput, setSearchStringInput] = useState('')
const addEngine = useCallback(() => {
const newEngines = {
...availableSearchEngines,
[aliasInput]: searchStringInput
}
console.log(newEngines)
setAvailableSearchEngines(newEngines)
setAliasInput('')
setSearchStringInput('')
}, [availableSearchEngines, aliasInput, searchStringInput, setAvailableSearchEngines, setAliasInput, setSearchStringInput])
const removeEngine = useCallback((alias:string) => {
const engines = { ...availableSearchEngines }
delete engines[alias]
setAvailableSearchEngines(engines)
setAliasInput('')
setSearchStringInput('')
}, [availableSearchEngines, setAvailableSearchEngines])
return (
<Box>
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell align="left" style={{ fontWeight: 'bold' }}>Alias</TableCell>
<TableCell align="left" style={{ fontWeight: 'bold' }}>Searchstring</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(availableSearchEngines).map(([name, searchString]) => (
<TableRow
key={name}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell align="left">{name}</TableCell>
<TableCell align="left">{searchString}</TableCell>
<TableCell>
<Button onClick={_ => removeEngine(name)} variant="outlined" size="small" color="warning" startIcon={<DeleteIcon />}>
Remove
</Button>
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell>
<TextField value={aliasInput} onChange={e => setAliasInput(e.target.value)} id="Alias-input" variant="standard" />
</TableCell>
<TableCell>
<TextField value={searchStringInput} onChange={e => setSearchStringInput(e.target.value)} id="Searchstring-input" variant="standard" />
</TableCell>
<TableCell>
<Button onClick={addEngine} variant="outlined" size="small" startIcon={<AddIcon />}>
Add
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</Box>
)
}
@@ -0,0 +1,46 @@
import { Switch } from "@mui/material";
import { useAtom } from "jotai";
import { editState } from "../../../state/edit.state";
import { gridState } from "../../../state/grid.state";
import { widgetState } from "../../../state/widget.state";
import { WidgetTypeValues } from "../../../types";
import { intitialWidgetData } from "../../../state/defaults";
import { useCallback } from "react";
import { incognitoAllowedState } from "../../../state/incogntio.state";
export function UtabSettings() {
const [editEnabled, setEditEnabled] = useAtom(editState)
const [grid, setGrid] = useAtom(gridState)
const [widgets, setWidgets] = useAtom(widgetState)
const createWidget = useCallback(
(type: WidgetTypeValues) => {
const data = intitialWidgetData(type)
const updatedWidgets = [...widgets, data]
setWidgets(updatedWidgets)
},
[widgets, setWidgets]
);
const [isIncognitoAllowed] = useAtom(incognitoAllowedState)
return (<>
Incognito enabled: {isIncognitoAllowed?'yes':'no'}
<label>
Editmode:
<Switch
checked={editEnabled}
onChange={() => setEditEnabled(!editEnabled)}
/>
</label>
<label>
Show Grid:
<Switch
checked={grid.show}
onChange={() => setGrid({ ...grid, show: !grid.show})}
/>
</label>
<button onClick={() => createWidget('clock')}>Add Clock</button>
<button onClick={() => createWidget('search')}>Add Search</button>
</>)
}
@@ -0,0 +1,50 @@
import { GlobalSettings } from "../../types";
const Colors = {
'red': '#FF3333',
'blue': '#3333FF'
} as const
const coloredGrindlines = (i:number, max: number) => {
let color;
if(max%4 === 0 && i === max/4){
color = Colors.blue
}
if(max%2 === 0 && i === max/2){
color = Colors.red
}
if((max*3)%4 === 0 && i === max*3/4){
color = Colors.blue
}
return { background: color }
}
type GridOverlayProps = {
grid: GlobalSettings['grid']
}
export function GridOverlay({grid}:GridOverlayProps){
return (<>
{grid.show && (
<div className="grid-overlay">
{Array.from({ length: grid.cols + 1 }).map((_, i) => (
<div key={`v${i}`}
className="grid-line vertical"
style={{
left: `${(i / grid.cols) * 100}%`,
...coloredGrindlines(i, grid.cols)
}} />
))}
{Array.from({ length: grid.rows + 1 }).map((_, i) => (
<div key={`h${i}`}
className="grid-line horizontal"
style={{
top: `${(i / grid.rows) * 100}%`,
...coloredGrindlines(i, grid.rows),
}} />
))}
</div>
)}
</>)
}
@@ -1,7 +1,8 @@
import React, { useCallback, useEffect, useState } from 'react';
import { GlobalSettings, SearchWidgetData, WidgetExport } from '../types';
import { searchEngineState } from '../state/searchEngines.state';
import { GlobalSettings, SearchWidgetData, WidgetExport } from '../../types';
import { availableEnginesState } from '../../state/searchEngines.state';
import { useAtom, useAtomValue } from 'jotai';
import { TextField } from '@mui/material';
interface SearchWidgetProps {
widget: SearchWidgetData;
@@ -12,7 +13,7 @@ const makeSearchString = (searchStringTemplate: string, query: string) => search
const SearchWidgetComponent: React.FC<SearchWidgetProps> = ({ widget, updateWidget }) => {
const [availableEngines] = useAtom(searchEngineState)
const [availableEngines] = useAtom(availableEnginesState)
const handleSearch = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
@@ -21,9 +22,6 @@ const SearchWidgetComponent: React.FC<SearchWidgetProps> = ({ widget, updateWidg
if (query) {
widget.engines.forEach(engine => {
if (!(engine in availableEngines)) {
return
}
setTimeout(() => {
window.open(makeSearchString(availableEngines[engine], query), '_blank')
}, 200)
@@ -34,14 +32,19 @@ const SearchWidgetComponent: React.FC<SearchWidgetProps> = ({ widget, updateWidg
);
return (
<form onSubmit={handleSearch}>
<form onSubmit={handleSearch} style={{ width: '100%', height: '100%', fontSize: '50cqmin' }}>
<input type="text" name="query" placeholder={`Search ${widget.engines}`} autoComplete="off" />
</form>
);
};
const SearchWidgetSettings = ({ widget, updateWidget }: SearchWidgetProps) => {
const [availableEngines] = useAtom(searchEngineState)
const [availableEngines] = useAtom(availableEnginesState)
useEffect(() => {
const engines = widget.engines.filter(engine => Object.keys(availableEngines).includes(engine))
updateWidget(widget.id, { engines })
}, [availableEngines])
const handleEngineChange = useCallback(
(e: any) => {
+4
View File
@@ -0,0 +1,4 @@
import { FrontendBindings } from '../../shared/RPC'
export const IpcService = FrontendBindings
@@ -1,5 +1,5 @@
import { GlobalSettings, WidgetData } from '../types';
import { DEFAULT_SETTINGS } from '../defaults';
import { GlobalSettings } from '../types';
import { DEFAULT_SETTINGS } from '../state/defaults';
export const settingsService = {
saveSetting: async <K extends keyof GlobalSettings>(key: K, value: GlobalSettings[K]) => {
@@ -13,4 +13,7 @@ export const settingsService = {
return loaded[key] !== undefined ? loaded[key] as GlobalSettings[K] : DEFAULT_SETTINGS[key]
},
exportSettings: async (): Promise<GlobalSettings> => {
return await chrome.storage.sync.get(null) as GlobalSettings
}
};
@@ -1,3 +1,3 @@
import { globalSettingsAtom } from "./globalSettings.atom";
import { globalSettingsAtom } from "./util/globalSettings.atom";
export const backgroundImageState = globalSettingsAtom('backgroundUrl')
@@ -1,48 +1,66 @@
import { GlobalSettings, SearchWidgetData, ClockWidgetData, WidgetTypes, WidgetData } from './types';
import { GlobalSettings, WidgetTypeValues, WidgetTypeMap } from '../types';
export const intitialWidgetData = (type: WidgetTypes): WidgetData => {
export const intitialWidgetData = (type: WidgetTypeValues): WidgetTypeMap[typeof type] => {
switch (type) {
case 'clock':
return {
gridHeight: 3,
gridWidth: 6,
gridX: 15,
gridY: 6,
height: 6,
width: 12,
y: 30,
x: 12,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
showSeconds: false,
use24Hour: true,
type: 'clock',
background: true,
id: `${type}-${Date.now()}`
} as ClockWidgetData
showIn: 'both',
id: `${type}-${Date.now()}`,
}
case 'search':
return {
gridHeight: 1,
gridWidth: 10,
gridX: 13,
gridY: 6,
height: 2,
width: 20,
y: 26,
x: 12,
id: `${type}-${Date.now()}`,
type: 'search',
engines: ['Google'],
background: true,
} as SearchWidgetData
showIn: 'both',
}
case 'globalsettings':
return {
background: false,
height: 2,
width: 2,
y: 2,
x: 2,
id: 'globalsettings',
type: 'globalsettings',
permanent: true,
showIn: 'both'
}
}
}
export const DEFAULT_SETTINGS: GlobalSettings = {
grid: {
show: true,
cols: 72,
rows: 36
},
backgroundUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/15/Cat_August_2010-4.jpg/2880px-Cat_August_2010-4.jpg',
widgets: [
intitialWidgetData('search'),
intitialWidgetData('clock')
intitialWidgetData('clock'),
intitialWidgetData('globalsettings')
],
editEnabled: true,
showGrid: true,
searchEngines: {
Google: 'https://www.google.com/search?q={s}',
Bing: 'https://www.bing.com/search?q={s}',
DuckDuckGo: 'https://duckduckgo.com/?q={s}',
Yandex: 'https://yandex.com/search/?text={s}'
}
};
};
+3
View File
@@ -0,0 +1,3 @@
import { globalSettingsAtom } from "./util/globalSettings.atom";
export const editState = globalSettingsAtom("editEnabled")
@@ -0,0 +1,3 @@
import { atom } from "jotai";
export const globalSettingsOpenState = atom(false)
+3
View File
@@ -0,0 +1,3 @@
import { globalSettingsAtom } from "./util/globalSettings.atom";
export const gridState = globalSettingsAtom('grid')
+8
View File
@@ -0,0 +1,8 @@
import { atom } from "jotai";
import { asyncStorageProxyAtom } from "./util/asyncStorageProxy.atom";
export const incognitoAllowedState = asyncStorageProxyAtom(
() => chrome.extension.isAllowedIncognitoAccess()
)
export const isIncognitoState = atom(chrome.extension.inIncognitoContext)
@@ -0,0 +1,3 @@
import { globalSettingsAtom } from "./util/globalSettings.atom";
export const availableEnginesState = globalSettingsAtom('searchEngines')
@@ -0,0 +1,24 @@
import { atom } from "jotai";
export const asyncStorageProxyAtom = <T>(
getter: () => Promise<T>,
setter: (value: T) => Promise<void> = async () => { throw new Error('AsyncProxyAtom setter not defined') }
) => {
const storage = atom<T | undefined>(undefined)
return atom(
async (get) => {
const existing = get(storage)
if (existing === undefined) {
const remoteValue = await getter()
return remoteValue
}
return existing
},
async (get, set, update: T) => {
await setter(update)
set(storage, update)
}
);
}
@@ -0,0 +1,12 @@
import { settingsService } from "../../services/settingsService";
import { GlobalSettings } from "../../types";
import { asyncStorageProxyAtom } from "./asyncStorageProxy.atom";
export const globalSettingsAtom = <T extends keyof GlobalSettings>(key:T) =>
asyncStorageProxyAtom(
async () => {const value = await settingsService.getSetting(key); console.log('reading', key, value); return value;},
(update) => {console.log('saving', key, update); return settingsService.saveSetting(key, update)}
)
+3
View File
@@ -0,0 +1,3 @@
import { globalSettingsAtom } from "./util/globalSettings.atom";
export const widgetState = globalSettingsAtom('widgets')
+58
View File
@@ -0,0 +1,58 @@
export type WidgetData<T extends WidgetTypeValues = WidgetTypeValues> = {
id: string;
type: T;
y: number;
x: number;
width: number;
height: number;
background: boolean;
permanent?: true;
showIn: 'standard' | 'incognito' | 'both'
[key: string]: any;
}
export type SearchWidgetData = WidgetData<'search'> & {
engines: Array<string>;
}
export type ClockWidgetData = WidgetData<'clock'> & {
showSeconds: boolean;
use24Hour: boolean;
timezone: string;
}
export type GlobalSettingsData = WidgetData<'globalsettings'> & {
permanent: true;
showIn: 'both'
}
export type WidgetTypeValues = (
SearchWidgetData |
ClockWidgetData |
GlobalSettingsData
)['type']
export type WidgetTypeMap<WidgetType extends WidgetTypeValues = WidgetTypeValues> = {
[Key in WidgetType]: WidgetData<Key>
} & {
'clock': ClockWidgetData,
'search': SearchWidgetData,
'globalsettings': GlobalSettingsData,
}
export interface GlobalSettings {
grid: {
show: boolean;
rows: number;
cols: number;
}
backgroundUrl: string;
widgets: WidgetData[];
editEnabled: boolean;
searchEngines: { [key in string]: string}
}
export type WidgetExport<W = React.FC<any>, S = (...a:any[]) =>JSX.Element > = {
widget: W,
settings: S
}
-6
View File
@@ -1,6 +0,0 @@
import React from 'react';
import { NewTab } from './components/NewTab';
export const App: React.FC = () => {
return <NewTab />;
};
@@ -1,68 +0,0 @@
import React, { useCallback, useState } from 'react';
import { CogwheelIcon } from './CogwheelIcon';
import { useAtom } from 'jotai';
import { editState } from '../state/edit.state';
import { showGridState } from '../state/showGrid.state';
import { intitialWidgetData } from '../defaults';
import { WidgetData, WidgetTypes } from '../types';
import { ModalOverlay } from './ModalOverlay';
import { backgroundImageState } from '../state/backgroundImage.state';
interface SettingsPanelProps {
addWidget: (widget: WidgetData) => void,
}
export const GlobalSettingsPanel: React.FC<SettingsPanelProps> = ({
addWidget
}) => {
const [isModalShowing, setModalShowing] = useState(false);
const [editEnabled, setEditEnabled] = useAtom(editState);
const [showGrid, setShowGrid] = useAtom(showGridState);
const [backgroundImage, setBackgroundImage] = useAtom(backgroundImageState)
const createWidget = (type: WidgetTypes) => addWidget(intitialWidgetData(type));
return (
<>
<button className="settings-toggle" onClick={() => setModalShowing(true)}>
<CogwheelIcon size={24} />
</button>
<ModalOverlay
setVisible={setModalShowing}
visible={isModalShowing}
>
<h2>Settings</h2>
<label>
Background URL:
<input
type="text"
value={backgroundImage}
onChange={(e) => setBackgroundImage(e.target.value)}
placeholder="Enter image URL"
/>
</label>
<label>
Drag Enabled:
<input
type="checkbox"
checked={editEnabled}
onChange={() => setEditEnabled(!editEnabled)}
/>
</label>
<label>
Show Grid:
<input
type="checkbox"
checked={showGrid}
onChange={() => setShowGrid(!showGrid)}
/>
</label>
<div className="widget-buttons">
<button onClick={() => createWidget('clock')}>Add Clock</button>
<button onClick={() => createWidget('search')}>Add Search</button>
</div>
</ModalOverlay >
</>
);
};
-106
View File
@@ -1,106 +0,0 @@
import React, { useState, useEffect, useCallback, Suspense, startTransition } from 'react';
import { useAtom, useAtomValue } from 'jotai'
import { WidgetFactory } from './WidgetFactory';
import { GlobalSettingsPanel } from './GlobalSettingsPanel';
import { showGridState } from '../state/showGrid.state';
import { WidgetData } from '../types';
import { settingsService } from '../services/settingsService';
import { backgroundImageState } from '../state/backgroundImage.state';
import { widgetState } from '../state/widget.state';
const GRID_COLS = 36;
const GRID_ROWS = 18;
const coloredGrindlines = (i:number, max: number) => {
let color;
if(i === max/2){
color = '#FF3333'
}
return { background: color }
}
export const NewTab: React.FC = () => {
const [gridSize, setGridSize] = useState({ width: window.innerWidth / GRID_COLS, height: window.innerHeight / GRID_ROWS });
const [showGrid] = useAtom(showGridState)
const [widgets, setWidgets] = useAtom(widgetState)
const [backgroundImage] = useAtom(backgroundImageState)
useEffect(() => {
const handleResize = () => {
setGridSize({
width: window.innerWidth / GRID_COLS,
height: window.innerHeight / GRID_ROWS
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [window.innerWidth, window.innerHeight])
useEffect(() => { document.body.style.backgroundImage = `url(${backgroundImage})` }, [backgroundImage]);
const updateWidget = useCallback(
<T extends WidgetData>(id: string, updates: Partial<T>) => {
console.log(id, updates)
const updatedWidgets = widgets.map(w =>
w.id === id ? { ...w, ...updates } : w
);
setWidgets(updatedWidgets)
},
[widgets, setWidgets, settingsService]
);
const addWidget = useCallback(
(data: WidgetData) => {
const updatedWidgets = [...widgets, data]
setWidgets(updatedWidgets)
},
[widgets, setWidgets, settingsService]
);
const removeWidget = useCallback(
(widgetId: string) => {
const updatedWidgets = widgets.filter(widget => widget.id !== widgetId)
setWidgets(updatedWidgets)
},
[widgets, setWidgets, settingsService]
);
return (
<Suspense fallback={<></>}>
<GlobalSettingsPanel
addWidget={addWidget}
/>
{showGrid && (
<div className="grid-overlay">
{Array.from({ length: GRID_COLS + 1 }).map((_, i) => (
<div key={`v${i}`}
className="grid-line vertical"
style={{
left: `${(i / GRID_COLS) * 100}%`,
...coloredGrindlines(i, GRID_COLS)
}} />
))}
{Array.from({ length: GRID_ROWS + 1 }).map((_, i) => (
<div key={`h${i}`}
className="grid-line horizontal"
style={{
top: `${(i / GRID_ROWS) * 100}%`,
...coloredGrindlines(i, GRID_ROWS),
}} />
))}
</div>
)}
{widgets.map(widget => (
<WidgetFactory
key={widget.id}
widget={widget}
gridSize={gridSize}
updateWidget={updateWidget}
removeWidget={removeWidget}
/>
))}
</ Suspense>
);
};
@@ -1,44 +0,0 @@
import { ReactElement } from "react"
import { WidgetData } from "../types"
type GenericSettingsProps = {
widgetData: WidgetData
updateWidget: (id: string, data: Partial<WidgetData>) => void
children: ReactElement | ReactElement[]
}
export function SettingsComponent({
widgetData,
updateWidget,
children
}: GenericSettingsProps) {
return <>
{children}
<label>
Width (grid cells):
<input
type="number"
min="1"
value={widgetData.gridWidth}
onChange={e => updateWidget(widgetData.id, { gridWidth: parseInt(e.target.value) })}
/>
</label>
<label>
Height (grid cells):
<input
type="number"
min="1"
value={widgetData.gridHeight}
onChange={e => updateWidget(widgetData.id, { gridHeight: parseInt(e.target.value) })}
/>
</label>
<label>
Background:
<input
type="checkbox"
checked={widgetData.background}
onChange={_ => updateWidget(widgetData.id, { background: !widgetData.background })}
/>
</label>
</>
}
-35
View File
@@ -1,35 +0,0 @@
import React from 'react';
import { WidgetData, WidgetExport, WidgetTypes } from '../types';
import { Widget } from './Widget';
import { SearchWidget } from './SearchWidget';
import { ClockWidget } from './ClockWidget';
interface WidgetFactoryProps {
widget: WidgetData;
gridSize: { width: number; height: number };
updateWidget: (id: string, data: Partial<WidgetData>) => void;
removeWidget: (id:string) => void;
}
const WidgetMap: { [key in WidgetTypes]: WidgetExport } = {
'clock': ClockWidget,
'search': SearchWidget
}
export const WidgetFactory: React.FC<WidgetFactoryProps> = ({ widget, gridSize, updateWidget, removeWidget }) => {
const Wdgt = WidgetMap[widget.type]
const component = <Wdgt.widget widget={widget} updateWidget={updateWidget}></Wdgt.widget>
const settings = Wdgt.settings({ widget: widget, updateWidget })
return (
<Widget
widgetData={widget}
gridSize={gridSize}
settingsContent={settings}
updateWidget={updateWidget}
removeWidget={removeWidget}
>
{component}
</Widget>
);
};
-3
View File
@@ -1,3 +0,0 @@
import { globalSettingsAtom } from "./globalSettings.atom";
export const editState = globalSettingsAtom("editEnabled")
-28
View File
@@ -1,28 +0,0 @@
import { settingsService } from "../services/settingsService";
import { atom } from "jotai";
import { GlobalSettings } from "../types";
import { DEFAULT_SETTINGS } from "../defaults";
export const globalSettingsAtom = <T extends keyof GlobalSettings>(key: T) => {
const storage = atom<GlobalSettings[T] | undefined>(undefined)
return atom(
async (get) => {
const existing = get(storage)
if (existing === undefined) {
const remoteValue = await settingsService.getSetting(key)
console.log("load", key, remoteValue)
return remoteValue
}
return existing
},
async (get, set, update: GlobalSettings[T]) => {
console.log("save", key, update)
await settingsService.saveSetting(key, update)
set(storage, update)
}
);
}
@@ -1,3 +0,0 @@
import { globalSettingsAtom } from "./globalSettings.atom";
export const searchEngineState = globalSettingsAtom('searchEngines')
-3
View File
@@ -1,3 +0,0 @@
import { globalSettingsAtom } from "./globalSettings.atom";
export const showGridState = globalSettingsAtom('showGrid')
-3
View File
@@ -1,3 +0,0 @@
import { globalSettingsAtom } from "./globalSettings.atom";
export const widgetState = globalSettingsAtom('widgets')
-37
View File
@@ -1,37 +0,0 @@
export interface WidgetData {
id: string;
type: WidgetTypes;
gridX: number;
gridY: number;
gridWidth: number;
gridHeight: number;
background: boolean;
[key: string]: any;
}
export interface SearchWidgetData extends WidgetData {
type: 'search';
engines: Array<string>;
}
export interface ClockWidgetData extends WidgetData {
type: 'clock';
showSeconds: boolean;
use24Hour: boolean;
timezone: string;
}
export type WidgetTypes = (SearchWidgetData | ClockWidgetData)['type']
export interface GlobalSettings {
backgroundUrl: string;
widgets: WidgetData[];
editEnabled: boolean;
showGrid: boolean;
searchEngines: { [key in string]: string}
}
export type WidgetExport<W = React.FC<any>, S = (...a:any[]) =>JSX.Element > = {
widget: W,
settings: S
}
+31
View File
@@ -0,0 +1,31 @@
import { ExposedEndpoints } from "../worker/Endpoints"
type NamedFunction<ArgT, RetT, NameT extends string> = { name: NameT } & ((a: ArgT) => RetT)
type Promised<T> = T extends Promise<any> ? T : Promise<T>
const wrapIpc = <A, R, N extends string = any>(fn: NamedFunction<A, R, N>): NamedFunction<A, R extends Promise<any> ? R : Promise<R>, N> => {
const func = function (message: A) {
return new Promise((res, rej) => {
chrome.runtime.sendMessage(
{
endpoint: fn.name,
args: message
},
(response) => { res(response) }
)
})
}
Object.defineProperty(fn, "name", { value: fn.name });
return func as NamedFunction<A, R extends Promise<any> ? R : Promise<R>, N>
}
export type RPCs = typeof ExposedEndpoints
export type FrontendIpcService = {
[endpoint in keyof RPCs]: NamedFunction<Parameters<RPCs[endpoint]>[0], Promised<ReturnType<RPCs[endpoint]>>, string>
}
export const FrontendBindings: FrontendIpcService = Object.entries(ExposedEndpoints)
.map(([endpoint, fn]) => ({ [endpoint]: wrapIpc(fn) }))
.reduce((prev, curr) => Object.assign(prev, curr), {}) as FrontendIpcService
+6
View File
@@ -0,0 +1,6 @@
export const ExposedEndpoints = {
writeFile: (message: { path: string, fileContent: string }) => {
console.log("writing", message.fileContent, "to", message.path)
return "OK"
}
} as const;
+9
View File
@@ -0,0 +1,9 @@
import { ExposedEndpoints } from "./Endpoints";
chrome.runtime.onMessage.addListener((param: { endpoint: keyof typeof ExposedEndpoints, args: any }, sender, sendReponse) => {
const returnValue = ExposedEndpoints[param.endpoint].apply({}, param.args)
if(returnValue !== undefined){
sendReponse(returnValue)
return true;
}
});