Workspaces in Workspaces all around

This commit is contained in:
2026-02-19 01:49:15 +00:00
parent 72bca6cd42
commit dd8ab84df5
84 changed files with 3047 additions and 749 deletions
+190
View File
@@ -0,0 +1,190 @@
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 { Widget } from '../Widget';
type Goal = {
id: string;
text: string;
done: boolean;
};
type DailyGoalsState = {
goals: Goal[];
lastDate: string;
streak: number;
lastCompletedDate: string | null;
};
const USER_STATE_KEY = ['USER_STATE'];
const GOALS_STATE_KEY = 'daily-goals';
const todayStr = () => new Date().toISOString().slice(0, 10);
const yesterdayStr = () => {
const d = new Date();
d.setDate(d.getDate() - 1);
return d.toISOString().slice(0, 10);
};
function useDailyGoalsState() {
const client = useClient();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const clientRef = useRef(client);
clientRef.current = client;
const { data: userState = {} } = useQuery<Record<string, unknown>>({
queryKey: USER_STATE_KEY,
enabled: isAuthenticated,
queryFn: () => client.get('/user/state'),
staleTime: Infinity,
});
const stored = userState[GOALS_STATE_KEY] as DailyGoalsState | undefined;
const today = todayStr();
const state: DailyGoalsState = (() => {
if (!stored) return { goals: [], lastDate: today, streak: 0, lastCompletedDate: null };
if (stored.lastDate === today) return stored;
// New day — check if yesterday had all goals completed for streak
const allDoneYesterday = stored.goals.length > 0 && stored.goals.every((g) => g.done);
const wasYesterday = stored.lastDate === yesterdayStr();
const streak = allDoneYesterday && wasYesterday ? stored.streak + 1 : allDoneYesterday ? 1 : 0;
const lastCompletedDate = allDoneYesterday ? stored.lastDate : stored.lastCompletedDate;
return {
goals: stored.goals.map((g) => ({ ...g, done: false })),
lastDate: today,
streak,
lastCompletedDate,
};
})();
const setState = useCallback(
(update: DailyGoalsState | ((prev: DailyGoalsState) => DailyGoalsState)) => {
const currentUserState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
const current = (currentUserState[GOALS_STATE_KEY] as DailyGoalsState | undefined) ?? state;
const next = typeof update === 'function' ? update(current) : update;
queryClient.setQueryData(USER_STATE_KEY, { ...currentUserState, [GOALS_STATE_KEY]: next });
clientRef.current.patch('/user/state', { [GOALS_STATE_KEY]: next }).catch(() => {});
},
[queryClient, state],
);
return [state, setState] as const;
}
let nextGoalId = Date.now();
export const DailyGoals = () => {
const [state, setState] = useDailyGoalsState();
const [newGoal, setNewGoal] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const addGoal = () => {
const text = newGoal.trim();
if (!text) return;
nextGoalId++;
setState((s) => ({
...s,
goals: [...s.goals, { id: String(nextGoalId), text, done: false }],
}));
setNewGoal('');
inputRef.current?.focus();
};
const toggleGoal = (id: string) => {
setState((s) => ({
...s,
goals: s.goals.map((g) => (g.id === id ? { ...g, done: !g.done } : g)),
}));
};
const removeGoal = (id: string) => {
setState((s) => ({
...s,
goals: s.goals.filter((g) => g.id !== id),
}));
};
const { goals, streak } = state;
const doneCount = goals.filter((g) => g.done).length;
return (
<Widget title="Daily Goals">
<div className="flex flex-col gap-2 px-4 pb-4">
{streak > 0 && (
<div className="flex items-center gap-1.5 text-xs text-warning">
<Flame size={12} />
<span>{streak} day streak</span>
</div>
)}
{goals.length > 0 && (
<div className="flex flex-col gap-1">
{goals.map((goal) => (
<div key={goal.id} className="group flex items-center gap-2">
<button
type="button"
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border cursor-pointer transition-colors ${
goal.done ? 'border-primary bg-primary text-primary-foreground' : 'border-border hover:border-primary'
}`}
onClick={() => toggleGoal(goal.id)}
>
{goal.done && (
<svg width="10" height="10" viewBox="0 0 10 10">
<path d="M2 5l2.5 2.5L8 3" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
<span className={`flex-1 text-sm ${goal.done ? 'line-through text-muted-foreground' : ''}`}>
{goal.text}
</span>
<button
type="button"
className="p-0.5 rounded cursor-pointer text-muted-foreground opacity-0 hover:text-destructive group-hover:opacity-100 transition-opacity"
onClick={() => removeGoal(goal.id)}
>
<Trash2 size={12} />
</button>
</div>
))}
</div>
)}
{goals.length > 0 && (
<div className="text-[10px] text-muted-foreground">
{doneCount}/{goals.length} completed
</div>
)}
<div className="flex items-center gap-1">
<input
ref={inputRef}
type="text"
placeholder="Add a goal..."
value={newGoal}
onChange={(ev) => setNewGoal(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
addGoal();
}
}}
className="flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded-md border border-border hover:bg-accent transition-colors cursor-pointer"
onClick={addGoal}
>
<Plus size={12} />
</button>
</div>
</div>
</Widget>
);
};
+414
View File
@@ -0,0 +1,414 @@
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 { allSounds, categories } from 'sounds';
import type { SoundAsset } from 'sounds';
import { Widget } from '../Widget';
type PomodoroSettings = {
focusMinutes: number;
breakMinutes: number;
longBreakMinutes: number;
sessionsBeforeLong: number;
focusEndSound: string | null;
breakEndSound: string | null;
};
type PomodoroState = {
settings: PomodoroSettings;
completedToday: number;
lastDate: string;
};
const DEFAULT_SETTINGS: PomodoroSettings = {
focusMinutes: 25,
breakMinutes: 5,
longBreakMinutes: 15,
sessionsBeforeLong: 4,
focusEndSound: 'confirmation-002',
breakEndSound: 'notification-pop',
};
const playSound = (name: string | null) => {
if (!name) return;
const sound = allSounds.find((s) => s.name === name);
if (sound) new Audio(sound.dataUri).play();
};
const USER_STATE_KEY = ['USER_STATE'];
const POMODORO_STATE_KEY = 'pomodoro-state';
const todayStr = () => new Date().toISOString().slice(0, 10);
function usePomodoroState() {
const client = useClient();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const clientRef = useRef(client);
clientRef.current = client;
const { data: userState = {} } = useQuery<Record<string, unknown>>({
queryKey: USER_STATE_KEY,
enabled: isAuthenticated,
queryFn: () => client.get('/user/state'),
staleTime: Infinity,
});
const stored = userState[POMODORO_STATE_KEY] as PomodoroState | undefined;
const state: PomodoroState = stored
? { ...stored, completedToday: stored.lastDate === todayStr() ? stored.completedToday : 0, lastDate: todayStr() }
: { settings: DEFAULT_SETTINGS, completedToday: 0, lastDate: todayStr() };
const setState = useCallback(
(update: PomodoroState | ((prev: PomodoroState) => PomodoroState)) => {
const currentUserState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
const current = (currentUserState[POMODORO_STATE_KEY] as PomodoroState | undefined) ?? state;
const next = typeof update === 'function' ? update(current) : update;
queryClient.setQueryData(USER_STATE_KEY, { ...currentUserState, [POMODORO_STATE_KEY]: next });
clientRef.current.patch('/user/state', { [POMODORO_STATE_KEY]: next }).catch(() => {});
},
[queryClient, state],
);
return [state, setState] as const;
}
type Phase = 'focus' | 'break' | 'long-break';
const phaseLabel: Record<Phase, string> = {
'focus': 'Focus',
'break': 'Break',
'long-break': 'Long Break',
};
const phaseColor: Record<Phase, string> = {
'focus': 'text-destructive',
'break': 'text-success',
'long-break': 'text-accent',
};
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
};
const formatSoundName = (name: string) =>
name
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
type SoundPickerProps = {
label: string;
value: string | null;
onChange: (name: string | null) => void;
};
const SoundPicker = ({ label, value, onChange }: SoundPickerProps) => {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [activeCategory, setActiveCategory] = useState<string>('all');
const [previewName, setPreviewName] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const filtered = useMemo(() => {
const source = activeCategory === 'all' ? allSounds : (categories.find((c) => c.id === activeCategory)?.sounds ?? []);
if (!search) return source;
const q = search.toLowerCase();
return source.filter((s) => s.name.toLowerCase().includes(q));
}, [search, activeCategory]);
const preview = (sound: SoundAsset) => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
const audio = new Audio(sound.dataUri);
audio.addEventListener('ended', () => setPreviewName(null));
audio.play();
audioRef.current = audio;
setPreviewName(sound.name);
};
const stopPreview = () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setPreviewName(null);
};
if (!open) {
return (
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">{label}</span>
<button
type="button"
className="flex items-center gap-1.5 rounded border border-border px-2 py-1 text-xs hover:bg-accent transition-colors cursor-pointer"
onClick={() => setOpen(true)}
>
<Volume2 size={10} />
{value ? formatSoundName(value) : 'None'}
</button>
</div>
);
}
return (
<div className="flex flex-col gap-1.5 rounded-md border border-border p-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">{label}</span>
<button
type="button"
className="text-[10px] text-muted-foreground hover:text-foreground cursor-pointer"
onClick={() => { stopPreview(); setOpen(false); }}
>
Done
</button>
</div>
<input
type="text"
placeholder="Search sounds..."
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="w-full rounded border border-border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
/>
<div className="flex flex-wrap gap-1">
{[{ id: 'all', label: 'All' }, ...categories.map((c) => ({ id: c.id, label: c.label }))].map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveCategory(tab.id)}
className={`px-1.5 py-0.5 rounded text-[10px] cursor-pointer transition-colors border ${
activeCategory === tab.id
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:border-foreground/30'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="max-h-32 overflow-y-auto flex flex-col gap-0.5">
<button
type="button"
className={`flex items-center gap-1.5 rounded px-2 py-1 text-left text-xs cursor-pointer transition-colors ${
value === null ? 'bg-primary/10 text-primary' : 'hover:bg-accent'
}`}
onClick={() => { stopPreview(); onChange(null); }}
>
None (silent)
</button>
{filtered.map((s) => (
<div key={s.name} className={`flex items-center gap-1 rounded px-2 py-1 text-xs ${value === s.name ? 'bg-primary/10 text-primary' : ''}`}>
<button
type="button"
className="shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
onClick={() => previewName === s.name ? stopPreview() : preview(s)}
>
{previewName === s.name ? <Square size={10} /> : <Play size={10} />}
</button>
<button
type="button"
className="flex-1 text-left cursor-pointer hover:text-foreground transition-colors truncate"
onClick={() => { onChange(s.name); }}
>
{formatSoundName(s.name)}
</button>
</div>
))}
</div>
</div>
);
};
const SettingsPanel = ({ settings, onSave }: { settings: PomodoroSettings; onSave: (s: PomodoroSettings) => void }) => {
const [draft, setDraft] = useState(settings);
const numericFields: { key: 'focusMinutes' | 'breakMinutes' | 'longBreakMinutes' | 'sessionsBeforeLong'; label: string }[] = [
{ key: 'focusMinutes', label: 'Focus (min)' },
{ key: 'breakMinutes', label: 'Break (min)' },
{ key: 'longBreakMinutes', label: 'Long break (min)' },
{ key: 'sessionsBeforeLong', label: 'Sessions before long' },
];
return (
<div className="flex flex-col gap-2 px-4 pb-4">
{numericFields.map(({ key, label }) => (
<div key={key} className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">{label}</span>
<input
type="number"
min={1}
max={120}
value={draft[key]}
onChange={(ev) => setDraft((d) => ({ ...d, [key]: Math.max(1, Number(ev.target.value)) }))}
className="w-16 rounded border border-border bg-background px-2 py-1 text-right text-xs outline-none focus:ring-1 focus:ring-ring"
/>
</div>
))}
<SoundPicker
label="Focus end sound"
value={draft.focusEndSound}
onChange={(name) => setDraft((d) => ({ ...d, focusEndSound: name }))}
/>
<SoundPicker
label="Break end sound"
value={draft.breakEndSound}
onChange={(name) => setDraft((d) => ({ ...d, breakEndSound: name }))}
/>
<button
type="button"
className="mt-1 flex items-center justify-center gap-1 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
onClick={() => onSave(draft)}
>
<Check size={12} />
Save
</button>
</div>
);
};
export const Pomodoro = () => {
const [pomState, setPomState] = usePomodoroState();
const { settings, completedToday } = pomState;
const [phase, setPhase] = useState<Phase>('focus');
const [running, setRunning] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [sessionCount, setSessionCount] = useState(0);
const phaseDuration = (p: Phase) => {
if (p === 'focus') return settings.focusMinutes * 60;
if (p === 'break') return settings.breakMinutes * 60;
return settings.longBreakMinutes * 60;
};
const [remaining, setRemaining] = useState(phaseDuration('focus'));
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const clearTimer = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
useEffect(() => {
if (!running) {
clearTimer();
return;
}
intervalRef.current = setInterval(() => {
setRemaining((r) => {
if (r <= 1) {
clearTimer();
setRunning(false);
if (phase === 'focus') {
playSound(settings.focusEndSound);
const newCount = sessionCount + 1;
setSessionCount(newCount);
setPomState((s) => ({ ...s, completedToday: s.completedToday + 1, lastDate: todayStr() }));
const isLongBreak = newCount % settings.sessionsBeforeLong === 0;
const nextPhase: Phase = isLongBreak ? 'long-break' : 'break';
setPhase(nextPhase);
setRemaining(phaseDuration(nextPhase));
} else {
playSound(settings.breakEndSound);
setPhase('focus');
setRemaining(phaseDuration('focus'));
}
return 0;
}
return r - 1;
});
}, 1000);
return clearTimer;
}, [running, phase, sessionCount, settings]);
const reset = () => {
clearTimer();
setRunning(false);
setPhase('focus');
setRemaining(phaseDuration('focus'));
};
if (showSettings) {
return (
<Widget title="Pomodoro">
<SettingsPanel
settings={settings}
onSave={(s) => {
setPomState((prev) => ({ ...prev, settings: s }));
setShowSettings(false);
reset();
}}
/>
</Widget>
);
}
const progress = 1 - remaining / phaseDuration(phase);
const circumference = 2 * Math.PI * 40;
const dashOffset = circumference * (1 - progress);
return (
<Widget title="Pomodoro">
<div className="flex flex-col items-center gap-3 px-4 pb-4">
<div className="relative flex items-center justify-center">
<svg width="100" height="100" className="-rotate-90">
<circle cx="50" cy="50" r="40" fill="none" stroke="currentColor" strokeWidth="4" className="text-muted/30" />
<circle
cx="50"
cy="50"
r="40"
fill="none"
stroke="currentColor"
strokeWidth="4"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
strokeLinecap="round"
className={phaseColor[phase]}
/>
</svg>
<span className="absolute text-xl font-bold tabular-nums">{formatTime(remaining)}</span>
</div>
<span className={`text-xs font-medium ${phaseColor[phase]}`}>{phaseLabel[phase]}</span>
<div className="flex items-center gap-2">
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full border border-border hover:bg-accent transition-colors cursor-pointer"
onClick={() => setRunning((r) => !r)}
>
{running ? <Pause size={14} /> : <Play size={14} />}
</button>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full border border-border hover:bg-accent transition-colors cursor-pointer"
onClick={reset}
>
<RotateCcw size={14} />
</button>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full border border-border hover:bg-accent transition-colors cursor-pointer"
onClick={() => setShowSettings(true)}
>
<Settings size={14} />
</button>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{completedToday} session{completedToday !== 1 ? 's' : ''} today</span>
</div>
</div>
</Widget>
);
};
@@ -0,0 +1,78 @@
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 { Widget } from '../Widget';
const USER_STATE_KEY = ['USER_STATE'];
const NOTES_STATE_KEY = 'quick-notes';
function useQuickNotes() {
const client = useClient();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const clientRef = useRef(client);
clientRef.current = client;
const { data: userState = {} } = useQuery<Record<string, unknown>>({
queryKey: USER_STATE_KEY,
enabled: isAuthenticated,
queryFn: () => client.get('/user/state'),
staleTime: Infinity,
});
const content = (userState[NOTES_STATE_KEY] as string | undefined) ?? '';
const setContent = useCallback(
(text: string) => {
const currentUserState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
queryClient.setQueryData(USER_STATE_KEY, { ...currentUserState, [NOTES_STATE_KEY]: text });
clientRef.current.patch('/user/state', { [NOTES_STATE_KEY]: text }).catch(() => {});
},
[queryClient],
);
return [content, setContent] as const;
}
export const QuickNotes = () => {
const [content, setContent] = useQuickNotes();
const [local, setLocal] = useState(content);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialized = useRef(false);
// Sync from server on first meaningful load
useEffect(() => {
if (initialized.current) return;
if (content) {
setLocal(content);
initialized.current = true;
}
}, [content]);
const handleChange = (text: string) => {
setLocal(text);
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => setContent(text), 500);
};
// Flush on unmount
useEffect(() => {
return () => {
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
};
}, []);
return (
<Widget title="Quick Notes">
<div className="px-4 pb-4">
<textarea
value={local}
onChange={(ev) => handleChange(ev.target.value)}
placeholder="Jot something down..."
className="h-32 w-full resize-y rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
/>
</div>
</Widget>
);
};
+268
View File
@@ -0,0 +1,268 @@
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 { Widget } from '../Widget';
import { getWeatherInfo } from './weather-codes';
type WeatherLocation = {
latitude: number;
longitude: number;
name: string;
};
type GeocodingResult = {
id: number;
name: string;
country: string;
admin1?: string;
latitude: number;
longitude: number;
};
type CurrentWeather = {
temperature_2m: number;
apparent_temperature: number;
weather_code: number;
wind_speed_10m: number;
relative_humidity_2m: number;
is_day: number;
};
type DailyWeather = {
time: string[];
temperature_2m_max: number[];
temperature_2m_min: number[];
weather_code: number[];
};
type ForecastResponse = {
current: CurrentWeather;
daily: DailyWeather;
};
const USER_STATE_KEY = ['USER_STATE'];
const LOCATION_STATE_KEY = 'weather-location';
function useWeatherLocation() {
const client = useClient();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const clientRef = useRef(client);
clientRef.current = client;
const { data: state = {} } = useQuery<Record<string, unknown>>({
queryKey: USER_STATE_KEY,
enabled: isAuthenticated,
queryFn: () => client.get('/user/state'),
staleTime: Infinity,
});
const location = (state[LOCATION_STATE_KEY] as WeatherLocation | undefined) ?? null;
const setLocation = useCallback(
(loc: WeatherLocation | null) => {
const currentState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
queryClient.setQueryData(USER_STATE_KEY, { ...currentState, [LOCATION_STATE_KEY]: loc });
clientRef.current.patch('/user/state', { [LOCATION_STATE_KEY]: loc }).catch(() => {});
},
[queryClient],
);
return [location, setLocation] as const;
}
const LocationSetup = ({ onSelect }: { onSelect: (loc: WeatherLocation) => void }) => {
const [query, setQuery] = useState('');
const [geoLoading, setGeoLoading] = useState(false);
const { data: results = [] } = useQuery<GeocodingResult[]>({
queryKey: ['geocoding', query],
enabled: query.length >= 2,
queryFn: async () => {
const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=5`);
const data = await res.json();
return data.results ?? [];
},
staleTime: 60_000,
});
const useGeolocation = () => {
if (!navigator.geolocation) return;
setGeoLoading(true);
navigator.geolocation.getCurrentPosition(
async (pos) => {
const { latitude, longitude } = pos.coords;
try {
const res = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${latitude.toFixed(2)},${longitude.toFixed(2)}&count=1`,
);
const data = await res.json();
const name = data.results?.[0]?.name ?? `${latitude.toFixed(2)}, ${longitude.toFixed(2)}`;
onSelect({ latitude, longitude, name });
} catch {
onSelect({ latitude, longitude, name: `${latitude.toFixed(2)}, ${longitude.toFixed(2)}` });
} finally {
setGeoLoading(false);
}
},
() => setGeoLoading(false),
);
};
return (
<div className="flex flex-col gap-3 px-4 pb-4">
<button
type="button"
className="flex items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm hover:bg-accent transition-colors cursor-pointer disabled:opacity-50"
onClick={useGeolocation}
disabled={geoLoading}
>
<MapPin size={14} />
{geoLoading ? 'Locating...' : 'Use my location'}
</button>
<div className="relative">
<input
type="text"
placeholder="Search city..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
/>
{results.length > 0 && (
<div className="absolute top-full left-0 z-10 mt-1 w-full rounded-md border border-border bg-popover shadow-md">
{results.map((r) => (
<button
key={r.id}
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent transition-colors cursor-pointer"
onClick={() => onSelect({ latitude: r.latitude, longitude: r.longitude, name: r.name })}
>
<MapPin size={12} className="shrink-0 text-muted-foreground" />
<span>
{r.name}
{r.admin1 ? `, ${r.admin1}` : ''}, {r.country}
</span>
</button>
))}
</div>
)}
</div>
</div>
);
};
const dayName = (dateStr: string) => {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString(undefined, { weekday: 'short' });
};
const WeatherDisplay = ({ location, onReset }: { location: WeatherLocation; onReset: () => void }) => {
const { data, isLoading } = useQuery<ForecastResponse>({
queryKey: ['weather', location.latitude, location.longitude],
queryFn: async () => {
const params = new URLSearchParams({
latitude: String(location.latitude),
longitude: String(location.longitude),
current: 'temperature_2m,apparent_temperature,weather_code,wind_speed_10m,relative_humidity_2m,is_day',
daily: 'temperature_2m_max,temperature_2m_min,weather_code',
timezone: 'auto',
forecast_days: '5',
});
const res = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`);
return res.json();
},
staleTime: 15 * 60_000,
});
if (isLoading || !data) {
return (
<div className="flex flex-col gap-3 px-4 pb-4">
<div className="h-16 animate-pulse rounded bg-muted" />
<div className="flex gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 flex-1 animate-pulse rounded bg-muted" />
))}
</div>
</div>
);
}
const { current, daily } = data;
const info = getWeatherInfo(current.weather_code, current.is_day === 1);
return (
<div className="flex flex-col gap-3 px-4 pb-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className="text-3xl">{info.icon}</span>
<div>
<div className="text-2xl font-bold tabular-nums">{Math.round(current.temperature_2m)}°C</div>
<div className="text-xs text-muted-foreground">{info.label}</div>
</div>
</div>
<button
type="button"
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
onClick={onReset}
>
<Settings size={14} />
</button>
</div>
<div className="flex gap-3 text-xs text-muted-foreground">
<span>Feels {Math.round(current.apparent_temperature)}°C</span>
<span>💧 {current.relative_humidity_2m}%</span>
<span>💨 {Math.round(current.wind_speed_10m)} km/h</span>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<MapPin size={10} />
<span>{location.name}</span>
</div>
<div className="flex gap-1">
{daily.time.map((date, i) => {
const dayInfo = getWeatherInfo(daily.weather_code[i]!, true);
return (
<div key={date} className="flex flex-1 flex-col items-center gap-0.5 rounded-md bg-muted/50 py-1.5">
<span className="text-[10px] font-medium text-muted-foreground">{dayName(date)}</span>
<span className="text-sm">{dayInfo.icon}</span>
<span className="text-[10px] tabular-nums">
{Math.round(daily.temperature_2m_max[i]!)}° / {Math.round(daily.temperature_2m_min[i]!)}°
</span>
</div>
);
})}
</div>
</div>
);
};
export const Weather = () => {
const [location, setLocation] = useWeatherLocation();
const [showSetup, setShowSetup] = useState(false);
if (!location || showSetup) {
return (
<Widget title="Weather">
<LocationSetup
onSelect={(loc) => {
setLocation(loc);
setShowSetup(false);
}}
/>
</Widget>
);
}
return (
<Widget title="Weather">
<WeatherDisplay
location={location}
onReset={() => setShowSetup(true)}
/>
</Widget>
);
};
@@ -0,0 +1,38 @@
type WeatherInfo = { icon: string; label: string };
const codes: Record<number, { day: WeatherInfo; night: WeatherInfo }> = {
0: { day: { icon: '☀️', label: 'Clear sky' }, night: { icon: '🌙', label: 'Clear sky' } },
1: { day: { icon: '🌤️', label: 'Mainly clear' }, night: { icon: '🌙', label: 'Mainly clear' } },
2: { day: { icon: '⛅', label: 'Partly cloudy' }, night: { icon: '☁️', label: 'Partly cloudy' } },
3: { day: { icon: '☁️', label: 'Overcast' }, night: { icon: '☁️', label: 'Overcast' } },
45: { day: { icon: '🌫️', label: 'Fog' }, night: { icon: '🌫️', label: 'Fog' } },
48: { day: { icon: '🌫️', label: 'Rime fog' }, night: { icon: '🌫️', label: 'Rime fog' } },
51: { day: { icon: '🌦️', label: 'Light drizzle' }, night: { icon: '🌧️', label: 'Light drizzle' } },
53: { day: { icon: '🌦️', label: 'Drizzle' }, night: { icon: '🌧️', label: 'Drizzle' } },
55: { day: { icon: '🌧️', label: 'Heavy drizzle' }, night: { icon: '🌧️', label: 'Heavy drizzle' } },
56: { day: { icon: '🌧️', label: 'Freezing drizzle' }, night: { icon: '🌧️', label: 'Freezing drizzle' } },
57: { day: { icon: '🌧️', label: 'Heavy freezing drizzle' }, night: { icon: '🌧️', label: 'Heavy freezing drizzle' } },
61: { day: { icon: '🌦️', label: 'Light rain' }, night: { icon: '🌧️', label: 'Light rain' } },
63: { day: { icon: '🌧️', label: 'Rain' }, night: { icon: '🌧️', label: 'Rain' } },
65: { day: { icon: '🌧️', label: 'Heavy rain' }, night: { icon: '🌧️', label: 'Heavy rain' } },
66: { day: { icon: '🌧️', label: 'Freezing rain' }, night: { icon: '🌧️', label: 'Freezing rain' } },
67: { day: { icon: '🌧️', label: 'Heavy freezing rain' }, night: { icon: '🌧️', label: 'Heavy freezing rain' } },
71: { day: { icon: '🌨️', label: 'Light snow' }, night: { icon: '🌨️', label: 'Light snow' } },
73: { day: { icon: '❄️', label: 'Snow' }, night: { icon: '❄️', label: 'Snow' } },
75: { day: { icon: '❄️', label: 'Heavy snow' }, night: { icon: '❄️', label: 'Heavy snow' } },
77: { day: { icon: '🌨️', label: 'Snow grains' }, night: { icon: '🌨️', label: 'Snow grains' } },
80: { day: { icon: '🌦️', label: 'Light showers' }, night: { icon: '🌧️', label: 'Light showers' } },
81: { day: { icon: '🌧️', label: 'Showers' }, night: { icon: '🌧️', label: 'Showers' } },
82: { day: { icon: '🌧️', label: 'Heavy showers' }, night: { icon: '🌧️', label: 'Heavy showers' } },
85: { day: { icon: '🌨️', label: 'Light snow showers' }, night: { icon: '🌨️', label: 'Light snow showers' } },
86: { day: { icon: '❄️', label: 'Heavy snow showers' }, night: { icon: '❄️', label: 'Heavy snow showers' } },
95: { day: { icon: '⛈️', label: 'Thunderstorm' }, night: { icon: '⛈️', label: 'Thunderstorm' } },
96: { day: { icon: '⛈️', label: 'Thunderstorm with hail' }, night: { icon: '⛈️', label: 'Thunderstorm with hail' } },
99: { day: { icon: '⛈️', label: 'Thunderstorm with heavy hail' }, night: { icon: '⛈️', label: 'Thunderstorm with heavy hail' } },
};
export const getWeatherInfo = (code: number, isDay: boolean): WeatherInfo => {
const entry = codes[code];
if (!entry) return { icon: '🌡️', label: 'Unknown' };
return isDay ? entry.day : entry.night;
};
+4
View File
@@ -4,6 +4,10 @@
"exports": {
"./Widget": "./Widget.tsx",
"./Clock": "./Clock/index.tsx",
"./Weather": "./Weather/index.tsx",
"./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"
+9 -1
View File
@@ -1,7 +1,15 @@
import { Clock as ClockIcon } from 'lucide-react';
import { Clock as ClockIcon, CloudSun, Timer, Target, StickyNote } from 'lucide-react';
import type { AppRegistryEntry } from '@/components/Workspace';
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 /> },
'weather': { name: 'Weather', icon: CloudSun, component: () => <Weather /> },
'pomodoro': { name: 'Pomodoro', icon: Timer, component: () => <Pomodoro /> },
'daily-goals': { name: 'Daily Goals', icon: Target, component: () => <DailyGoals /> },
'quick-notes': { name: 'Quick Notes', icon: StickyNote, component: () => <QuickNotes /> },
};