widgets
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import type { WidgetRegistryMeta } from 'officerdev';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Clock as ClockIcon } from 'lucide-react';
|
||||
import { Widget } from '../Widget';
|
||||
|
||||
export const Clock = () => {
|
||||
@@ -21,3 +23,7 @@ export const Clock = () => {
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export const widgetRegistryMetas: WidgetRegistryMeta[] = [
|
||||
{ key: 'clock', name: 'Clock', icon: ClockIcon, component: Clock },
|
||||
];
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { WidgetRegistryMeta } from 'officerdev';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { Plus, Trash2, Flame } from 'lucide-react';
|
||||
import { Plus, Target, Trash2, Flame } from 'lucide-react';
|
||||
import { Widget } from '../Widget';
|
||||
|
||||
type Goal = {
|
||||
@@ -188,3 +189,7 @@ export const DailyGoals = () => {
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export const widgetRegistryMetas: WidgetRegistryMeta[] = [
|
||||
{ key: 'daily-goals', name: 'Daily Goals', icon: Target, component: DailyGoals },
|
||||
];
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { WidgetRegistryMeta } from 'officerdev';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { Pause, Play, RotateCcw, Settings, Check, Volume2, Square } from 'lucide-react';
|
||||
import { Pause, Play, RotateCcw, Settings, Check, Timer, Volume2, Square } from 'lucide-react';
|
||||
import { allSounds, categories } from 'sounds';
|
||||
import type { SoundAsset } from 'sounds';
|
||||
import { Widget } from '../Widget';
|
||||
@@ -412,3 +413,7 @@ export const Pomodoro = () => {
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export const widgetRegistryMetas: WidgetRegistryMeta[] = [
|
||||
{ key: 'pomodoro', name: 'Pomodoro', icon: Timer, component: Pomodoro },
|
||||
];
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { WidgetRegistryMeta } from 'officerdev';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { StickyNote } from 'lucide-react';
|
||||
import { Widget } from '../Widget';
|
||||
|
||||
const USER_STATE_KEY = ['USER_STATE'];
|
||||
@@ -76,3 +78,7 @@ export const QuickNotes = () => {
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export const widgetRegistryMetas: WidgetRegistryMeta[] = [
|
||||
{ key: 'quick-notes', name: 'Quick Notes', icon: StickyNote, component: QuickNotes },
|
||||
];
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { WidgetRegistryMeta } from 'officerdev';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { MapPin, Settings } from 'lucide-react';
|
||||
import { CloudSun, MapPin, Settings } from 'lucide-react';
|
||||
import { Widget } from '../Widget';
|
||||
import { getWeatherInfo } from './weather-codes';
|
||||
|
||||
@@ -266,3 +267,7 @@ export const Weather = () => {
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export const widgetRegistryMetas: WidgetRegistryMeta[] = [
|
||||
{ key: 'weather', name: 'Weather', icon: CloudSun, component: Weather },
|
||||
];
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { widgetRegistry } from '../widget-registry';
|
||||
|
||||
type WidgetPickerProps = {
|
||||
onSelect: (widgetType: string) => void;
|
||||
};
|
||||
|
||||
export const WidgetPicker = ({ onSelect }: WidgetPickerProps) => {
|
||||
const entries = Object.entries(widgetRegistry);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 max-w-xs">
|
||||
{entries.map(([key, entry]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg border border-border/50 bg-card/80 backdrop-blur-sm px-3 py-3 text-foreground hover:border-border hover:bg-card transition-colors cursor-pointer"
|
||||
onClick={() => onSelect(key)}
|
||||
>
|
||||
<entry.icon className="h-5 w-5" />
|
||||
<span className="text-xs font-medium leading-tight text-center">{entry.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,238 +0,0 @@
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { widgetRegistry } from '../widget-registry';
|
||||
import { WidgetPicker } from './WidgetPicker';
|
||||
|
||||
type Position = { x: number; y: number }; // percentages (0–100)
|
||||
|
||||
type WidgetInstance = {
|
||||
id: string;
|
||||
widgetType: string;
|
||||
position: Position;
|
||||
};
|
||||
|
||||
type WidgetPanelConfig = {
|
||||
instances: WidgetInstance[];
|
||||
nextId: number;
|
||||
};
|
||||
|
||||
const USER_STATE_KEY = ['USER_STATE'];
|
||||
|
||||
function useWidgetPanelState(panelId: string) {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const stateKey = `widget-panel:${panelId}`;
|
||||
|
||||
const { data: state = {} } = useQuery<Record<string, unknown>>({
|
||||
queryKey: USER_STATE_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get('/user/state'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const config = (state[stateKey] as WidgetPanelConfig | undefined) ?? { instances: [], nextId: 0 };
|
||||
|
||||
const setConfig = useCallback(
|
||||
(update: WidgetPanelConfig | ((prev: WidgetPanelConfig) => WidgetPanelConfig)) => {
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
|
||||
const current = (currentState[stateKey] as WidgetPanelConfig | undefined) ?? { instances: [], nextId: 0 };
|
||||
const next = typeof update === 'function' ? update(current) : update;
|
||||
|
||||
queryClient.setQueryData(USER_STATE_KEY, { ...currentState, [stateKey]: next });
|
||||
clientRef.current.patch('/user/state', { [stateKey]: next }).catch(() => {});
|
||||
},
|
||||
[stateKey, queryClient],
|
||||
);
|
||||
|
||||
return [config, setConfig] as const;
|
||||
}
|
||||
|
||||
// --- DraggableWidget ---
|
||||
|
||||
type DraggableWidgetProps = {
|
||||
instance: WidgetInstance;
|
||||
panelRef: React.RefObject<HTMLDivElement | null>;
|
||||
onMove: (pos: Position) => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
type DragState = {
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
widgetWPct: number;
|
||||
widgetHPct: number;
|
||||
panelW: number;
|
||||
panelH: number;
|
||||
};
|
||||
|
||||
const DraggableWidget = ({ instance, panelRef, onMove, onRemove }: DraggableWidgetProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(ev: ReactPointerEvent) => {
|
||||
const target = ev.target as HTMLElement;
|
||||
if (!target.closest('[data-widget-header]') || target.closest('button')) return;
|
||||
const widgetRect = containerRef.current?.getBoundingClientRect();
|
||||
const panelRect = panelRef.current?.getBoundingClientRect();
|
||||
if (!widgetRect || !panelRect) return;
|
||||
dragRef.current = {
|
||||
startX: ev.clientX,
|
||||
startY: ev.clientY,
|
||||
originX: instance.position.x,
|
||||
originY: instance.position.y,
|
||||
widgetWPct: (widgetRect.width / panelRect.width) * 100,
|
||||
widgetHPct: (widgetRect.height / panelRect.height) * 100,
|
||||
panelW: panelRect.width,
|
||||
panelH: panelRect.height,
|
||||
};
|
||||
containerRef.current?.setPointerCapture(ev.pointerId);
|
||||
},
|
||||
[instance.position, panelRef],
|
||||
);
|
||||
|
||||
const endDrag = useCallback(
|
||||
(ev: ReactPointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
dragRef.current = null;
|
||||
containerRef.current?.releasePointerCapture(ev.pointerId);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(ev: ReactPointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const panelRect = panelRef.current?.getBoundingClientRect();
|
||||
if (panelRect && (ev.clientX < panelRect.left || ev.clientX > panelRect.right || ev.clientY < panelRect.top || ev.clientY > panelRect.bottom)) {
|
||||
endDrag(ev);
|
||||
return;
|
||||
}
|
||||
const d = dragRef.current;
|
||||
const deltaXPct = ((ev.clientX - d.startX) / d.panelW) * 100;
|
||||
const deltaYPct = ((ev.clientY - d.startY) / d.panelH) * 100;
|
||||
onMove({
|
||||
x: Math.max(0, Math.min(d.originX + deltaXPct, 100 - d.widgetWPct)),
|
||||
y: Math.max(0, Math.min(d.originY + deltaYPct, 100 - d.widgetHPct)),
|
||||
});
|
||||
},
|
||||
[onMove, panelRef, endDrag],
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback(
|
||||
(ev: ReactPointerEvent) => {
|
||||
endDrag(ev);
|
||||
},
|
||||
[endDrag],
|
||||
);
|
||||
|
||||
const entry = widgetRegistry[instance.widgetType];
|
||||
if (!entry) return null;
|
||||
const WidgetComponent = entry.component;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute group"
|
||||
style={{ left: `${instance.position.x}%`, top: `${instance.position.y}%` }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -top-2 -right-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-destructive text-destructive-foreground opacity-0 shadow transition-opacity cursor-pointer group-hover:opacity-100"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
<WidgetComponent panelId={instance.id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- WidgetPanel ---
|
||||
|
||||
export const WidgetPanel = ({ panelId }: { panelId: string }) => {
|
||||
const [config, setConfig] = useWidgetPanelState(panelId);
|
||||
const [instances, setInstances] = useState<WidgetInstance[]>(config.instances);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const nextId = useRef(config.nextId);
|
||||
const initialized = useRef(false);
|
||||
|
||||
// Sync from persisted state on first load
|
||||
useEffect(() => {
|
||||
if (initialized.current) return;
|
||||
if (config.instances.length > 0 || config.nextId > 0) {
|
||||
setInstances(config.instances);
|
||||
nextId.current = config.nextId;
|
||||
initialized.current = true;
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// Persist whenever instances change (skip the initial mount)
|
||||
const mounted = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!mounted.current) {
|
||||
mounted.current = true;
|
||||
return;
|
||||
}
|
||||
setConfig({ instances, nextId: nextId.current });
|
||||
}, [instances, setConfig]);
|
||||
|
||||
const addWidget = useCallback((widgetType: string) => {
|
||||
nextId.current++;
|
||||
const id = `widget-${nextId.current}`;
|
||||
const offset = (nextId.current % 5) * 5;
|
||||
setInstances((prev) => [...prev, { id, widgetType, position: { x: 2 + offset, y: 2 + offset } }]);
|
||||
setShowPicker(false);
|
||||
}, []);
|
||||
|
||||
const removeWidget = useCallback((id: string) => {
|
||||
setInstances((prev) => prev.filter((w) => w.id !== id));
|
||||
}, []);
|
||||
|
||||
const updatePosition = useCallback((id: string, pos: Position) => {
|
||||
setInstances((prev) => prev.map((w) => (w.id === id ? { ...w, position: pos } : w)));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className="relative h-full w-full overflow-hidden">
|
||||
{instances.map((instance) => (
|
||||
<DraggableWidget
|
||||
key={instance.id}
|
||||
instance={instance}
|
||||
panelRef={panelRef}
|
||||
onMove={(pos) => updatePosition(instance.id, pos)}
|
||||
onRemove={() => removeWidget(instance.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className={`absolute bottom-4 right-4 flex flex-col items-end transition-opacity ${showPicker ? 'opacity-100' : 'opacity-0 group-hover/panel:opacity-100'}`}>
|
||||
{showPicker && (
|
||||
<div className="mb-2 rounded-xl border border-border bg-card p-3 shadow-lg">
|
||||
<WidgetPicker onSelect={addWidget} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-colors cursor-pointer"
|
||||
onClick={() => setShowPicker((v) => !v)}
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,8 +8,6 @@
|
||||
"./Pomodoro": "./Pomodoro/index.tsx",
|
||||
"./DailyGoals": "./DailyGoals/index.tsx",
|
||||
"./QuickNotes": "./QuickNotes/index.tsx",
|
||||
"./widget-registry": "./widget-registry.tsx",
|
||||
"./WidgetPanel": "./WidgetPanel/index.tsx",
|
||||
"./Workspaces": "./Workspaces/index.tsx"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Clock as ClockIcon, CloudSun, Timer, Target, StickyNote } from 'lucide-react';
|
||||
import type { AppRegistryEntry } from 'officerdev';
|
||||
import { Clock } from './Clock/index';
|
||||
import { Weather } from './Weather/index';
|
||||
import { Pomodoro } from './Pomodoro/index';
|
||||
import { DailyGoals } from './DailyGoals/index';
|
||||
import { QuickNotes } from './QuickNotes/index';
|
||||
|
||||
export const widgetRegistry: Record<string, AppRegistryEntry> = {
|
||||
'clock': { name: 'Clock', icon: ClockIcon, component: () => <Clock />, availableOnPanel: false },
|
||||
'weather': { name: 'Weather', icon: CloudSun, component: () => <Weather />, availableOnPanel: false },
|
||||
'pomodoro': { name: 'Pomodoro', icon: Timer, component: () => <Pomodoro />, availableOnPanel: false },
|
||||
'daily-goals': { name: 'Daily Goals', icon: Target, component: () => <DailyGoals />, availableOnPanel: false },
|
||||
'quick-notes': { name: 'Quick Notes', icon: StickyNote, component: () => <QuickNotes />, availableOnPanel: false },
|
||||
};
|
||||
Reference in New Issue
Block a user