Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,25 @@ Press <kbd>Ctrl</kbd>+<kbd>/</kbd> to open the shortcut list. Click a key to cha
| | Focus summary | <kbd>Alt</kbd>+<kbd>H</kbd> |
| | About Me · Tags | <kbd>Alt</kbd>+<kbd>P</kbd> · <kbd>Alt</kbd>+<kbd>T</kbd> |
| | Open / close menu | <kbd>Alt</kbd>+<kbd>M</kbd> |
| | Back (Tags page) | <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>B</kbd> |
| Focus timer | Start / pause / resume | <kbd>Alt</kbd>+<kbd>S</kbd> |
| | Switch focus / break | <kbd>Alt</kbd>+<kbd>B</kbd> |
| | End or complete session | <kbd>Alt</kbd>+<kbd>X</kbd> |
| | Edit focus goal · Choose animation | <kbd>Alt</kbd>+<kbd>G</kbd> · <kbd>Alt</kbd>+<kbd>A</kbd> |
| | Add tag | <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>T</kbd> |
| | Reset session · Extend finished session | <kbd>Alt</kbd>+<kbd>R</kbd> · <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd> |
| | Edit minutes | <kbd>Alt</kbd>+<kbd>I</kbd> |
| | Auto-shuffle animations on/off · Next animation | <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>A</kbd> · <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>C</kbd> |
| Lists | New item on the current page | <kbd>Alt</kbd>+<kbd>N</kbd> |
| | Post to Boost · Attach media | <kbd>Ctrl</kbd>+<kbd>Enter</kbd> · <kbd>Alt</kbd>+<kbd>U</kbd> |
| | Public / private post | <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>V</kbd> |
| | Show / hide archived · Show / hide backlog | <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>H</kbd> · <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>K</kbd> |
| | Switch 7 / 30 days (Habits) | <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>D</kbd> |
| | Previous · next period | <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>←</kbd> · <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>→</kbd> |
| | Next chart range (Focus summary) | <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>P</kbd> |
| App | Edit display name | <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd> |
| | Save page (About Me) | <kbd>Ctrl</kbd>+<kbd>S</kbd> |
| | Sounds · Notifications | <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> · <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>O</kbd> |
| | Show or hide the shortcut list | <kbd>Ctrl</kbd>+<kbd>/</kbd> |

Shortcuts without <kbd>Ctrl</kbd>, <kbd>Alt</kbd> or <kbd>Meta</kbd> are ignored while you type in a field. Hover a button to see its current shortcut. On Linux, Chrome reserves <kbd>Alt</kbd>+<kbd>1</kbd>…<kbd>8</kbd> for switching tabs; rebind those if they clash.
Expand Down
6 changes: 5 additions & 1 deletion src/components/boost/BoostFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import { useEffect, useRef, useState, type DragEvent, type FormEvent } from "react";
import { Globe, Handshake, Loader2, Lock, Paperclip, Send, Sparkles, Trash2, X } from "../icons";
import { useHotkey } from "../hotkeys/HotkeysProvider";
import { hotkeyHint } from "../focus/GoalForm";
import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider";
import { getDisplayName } from "../lib/profile";
import { getLocalUserId, localDb, type Row } from "../lib/local-db";
import { PostContent } from "./PostContent";
Expand Down Expand Up @@ -106,6 +107,8 @@ export function BoostFeed() {
useHotkey("list.new", () => composerRef.current?.focus());
useHotkey("boost.post", () => formRef.current?.requestSubmit());
useHotkey("boost.attach", () => fileInputRef.current?.click());
useHotkey("boost.visibility", () => setIsPublic((current) => !current), !loading);
const visibilityCombo = useHotkeyLabel("boost.visibility");
const dragDepthRef = useRef(0);

useEffect(() => {
Expand Down Expand Up @@ -313,6 +316,7 @@ export function BoostFeed() {
<button
type="button"
onClick={() => setIsPublic(!isPublic)}
title={hotkeyHint(isPublic ? "Make private" : "Make public", visibilityCombo)}
className="flex items-center gap-2 text-sm text-gray-400 hover:text-white transition-colors"
>
{isPublic ? <Globe className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
Expand Down
5 changes: 4 additions & 1 deletion src/components/focus/AnimationPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export function AnimationPicker({
onAutoShuffleToggle,
}: AnimationPickerProps) {
const toggleCombo = useHotkeyLabel("timer.animation");
const shuffleCombo = useHotkeyLabel("timer.shuffle");
const nextCombo = useHotkeyLabel("timer.nextAnimation");
return (
<div className="mb-8 px-2 sm:px-0 relative">
<button
Expand Down Expand Up @@ -98,6 +100,7 @@ export function AnimationPicker({
<button
type="button"
onClick={onAutoShuffleToggle}
title={hotkeyHint("Auto-shuffle animations", shuffleCombo)}
className={`w-full flex items-center justify-between p-3 rounded-lg border-2 transition-all ${
autoShuffle ? "border-blue-500 bg-blue-900/30" : "border-gray-700 bg-gray-900 hover:border-gray-600"
}`}
Expand All @@ -118,7 +121,7 @@ export function AnimationPicker({
</div>
</button>

<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-3 gap-3" title={hotkeyHint("Next animation", nextCombo)}>
{ANIMATIONS.map(({ type, title }) => {
const isSelected = selectedAnimation === type;
return (
Expand Down
19 changes: 16 additions & 3 deletions src/components/focus/FocusTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
localStorage.setItem(SELECTED_ANIMATION_KEY, type);
};

const selectNextAnimation = () => {
const index = ANIMATIONS.findIndex((animation) => animation.type === selectedAnimation);
selectAnimation(ANIMATIONS[(index + 1) % ANIMATIONS.length].type);
};

const rerollAnimation = () => {
if (autoShuffle) setSelectedAnimation(randomAnimationOtherThan(selectedAnimation));
};
Expand Down Expand Up @@ -533,9 +538,16 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
useHotkey("timer.end", endCurrent, isActive);
useHotkey("timer.switch", switchMode, isActive);
useHotkey("timer.animation", () => setShowPicker((visible) => !visible), isActive && isIdle);
useHotkey("timer.shuffle", toggleAutoShuffle, isActive && isIdle);
useHotkey("timer.nextAnimation", selectNextAnimation, isActive && isIdle);
const isSessionComplete = !isIdle && !isOnBreak && focus.isCompleted;
useHotkey("timer.reset", resetTimer, isActive && !isIdle && !isDirectBreak && !isOnBreak);
useHotkey("timer.extend", () => void extendFocus(), isActive && isSessionComplete);
const toggleCombo = useHotkeyLabel("timer.toggle");
const endCombo = useHotkeyLabel("timer.end");
const switchCombo = useHotkeyLabel("timer.switch");
const resetCombo = useHotkeyLabel("timer.reset");
const extendCombo = useHotkeyLabel("timer.extend");
const inputForPhase = isOnBreak ? breakMinutes : inputMinutes;
const frameState: FrameState = {
isOnBreak,
Expand Down Expand Up @@ -631,7 +643,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
/>
</div>

{focus.isCompleted && !isOnBreak ? (
{isSessionComplete ? (
<div className="bg-green-900/20 border border-green-700/40 rounded-xl px-3 py-2.5 mb-2.5 text-left">
<div className="flex items-center justify-center gap-1.5 mb-2.5">
<CheckCircle2 className="w-4 h-4 text-green-400" />
Expand All @@ -654,6 +666,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
<span className="text-gray-500 text-xs">min</span>
<button
onClick={extendFocus}
title={hotkeyHint("Extend", extendCombo)}
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white rounded-md text-xs font-medium transition-colors"
>
Extend
Expand Down Expand Up @@ -731,7 +744,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
<Play className="w-5 h-5" />
Start Break
</TimerButton>
<TimerButton onClick={resetTimer} className={`${BUTTON_BASE} px-4 py-3 bg-gray-500 hover:bg-gray-600`}>
<TimerButton onClick={resetTimer} title={hotkeyHint("Reset", resetCombo)} className={`${BUTTON_BASE} px-4 py-3 bg-gray-500 hover:bg-gray-600`}>
<RotateCcw className="w-5 h-5" />
Reset
</TimerButton>
Expand All @@ -753,7 +766,7 @@ export const FocusTimer = forwardRef<FocusTimerHandle, FocusTimerProps>(function
<Target className={ICON_SMALL} />
<span>Done</span>
</TimerButton>
<TimerButton onClick={resetTimer} className={`${BUTTON_SMALL} bg-gray-500 hover:bg-gray-600`}>
<TimerButton onClick={resetTimer} title={hotkeyHint("Reset", resetCombo)} className={`${BUTTON_SMALL} bg-gray-500 hover:bg-gray-600`}>
<RotateCcw className={ICON_SMALL} />
Reset
</TimerButton>
Expand Down
8 changes: 8 additions & 0 deletions src/components/focus/GoalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,12 @@ export function GoalForm({
goalInput.current?.focus();
goalInput.current?.select();
}, isActive);
const minutesInput = useRef<HTMLInputElement>(null);
useHotkey("timer.minutes", () => {
minutesInput.current?.focus();
minutesInput.current?.select();
}, isActive);
const minutesCombo = useHotkeyLabel("timer.minutes");
// -1 = typing a fresh goal; otherwise the index into goalHistory being previewed.
const [historyIndex, setHistoryIndex] = useState(-1);
const [draftGoal, setDraftGoal] = useState("");
Expand Down Expand Up @@ -327,6 +333,7 @@ export function GoalForm({
Minutes
</FieldLabel>
<input
ref={minutesInput}
type="number"
value={inputMinutes}
onChange={(event) => onMinutesChange(event.target.value)}
Expand All @@ -335,6 +342,7 @@ export function GoalForm({
}}
min="1"
max="120"
title={hotkeyHint("Minutes", minutesCombo)}
className={INPUT_CLASS}
/>
</div>
Expand Down
16 changes: 16 additions & 0 deletions src/components/hotkeys/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,35 @@ export const HOTKEY_ACTIONS: readonly HotkeyAction[] = [
{ id: "nav.aboutme", group: "Navigate", label: "About Me", defaultCombo: "Alt+P" },
{ id: "nav.tags", group: "Navigate", label: "Tags", defaultCombo: "Alt+T" },
{ id: "nav.menu", group: "Navigate", label: "Open / close menu", defaultCombo: "Alt+M" },
{ id: "page.back", group: "Navigate", label: "Back", defaultCombo: "Alt+Shift+B" },

{ id: "timer.toggle", group: "Focus timer", label: "Start / pause / resume", defaultCombo: "Alt+S" },
{ id: "timer.switch", group: "Focus timer", label: "Switch focus / break", defaultCombo: "Alt+B" },
{ id: "timer.end", group: "Focus timer", label: "End or complete session", defaultCombo: "Alt+X" },
{ id: "timer.goal", group: "Focus timer", label: "Edit focus goal", defaultCombo: "Alt+G" },
{ id: "timer.animation", group: "Focus timer", label: "Choose animation", defaultCombo: "Alt+A" },
{ id: "timer.addTag", group: "Focus timer", label: "Add tag", defaultCombo: "Alt+Shift+T" },
{ id: "timer.reset", group: "Focus timer", label: "Reset session", defaultCombo: "Alt+R" },
{ id: "timer.extend", group: "Focus timer", label: "Extend finished session", defaultCombo: "Alt+Shift+E" },
{ id: "timer.minutes", group: "Focus timer", label: "Edit minutes", defaultCombo: "Alt+I" },
{ id: "timer.shuffle", group: "Focus timer", label: "Auto-shuffle animations on/off", defaultCombo: "Alt+Shift+A" },
{ id: "timer.nextAnimation", group: "Focus timer", label: "Next animation", defaultCombo: "Alt+Shift+C" },

{ id: "list.new", group: "Lists", label: "New item on current page", defaultCombo: "Alt+N" },
{ id: "boost.post", group: "Lists", label: "Post to Boost", defaultCombo: "Ctrl+Enter" },
{ id: "boost.attach", group: "Lists", label: "Attach media to post", defaultCombo: "Alt+U" },
{ id: "boost.visibility", group: "Lists", label: "Public / private post", defaultCombo: "Alt+Shift+V" },
{ id: "list.archived", group: "Lists", label: "Show / hide archived", defaultCombo: "Alt+Shift+H" },
{ id: "list.backlog", group: "Lists", label: "Show / hide backlog", defaultCombo: "Alt+Shift+K" },
{ id: "list.range", group: "Lists", label: "Switch 7 / 30 days", defaultCombo: "Alt+Shift+D" },
{ id: "list.prev", group: "Lists", label: "Previous period", defaultCombo: "Alt+Shift+Left" },
{ id: "list.next", group: "Lists", label: "Next period", defaultCombo: "Alt+Shift+Right" },
{ id: "history.period", group: "Lists", label: "Next chart range", defaultCombo: "Alt+Shift+P" },

{ id: "app.displayName", group: "App", label: "Edit display name", defaultCombo: "Alt+Shift+N" },
{ id: "page.save", group: "App", label: "Save page", defaultCombo: "Ctrl+S" },
{ id: "app.sounds", group: "App", label: "Sounds", defaultCombo: "Alt+Shift+S" },
{ id: "app.notifications", group: "App", label: "Notifications", defaultCombo: "Alt+Shift+O" },
{ id: "app.fullPage", group: "App", label: "Open full page (extension popup)", defaultCombo: "Alt+Enter" },
{ id: "app.shortcuts", group: "App", label: "Show or hide this list", defaultCombo: "Ctrl+/" },
];
Expand Down
9 changes: 9 additions & 0 deletions src/components/pages/AboutMePage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client";

import { useEffect, useState } from "react";
import { hotkeyHint } from "../focus/GoalForm";
import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider";
import { Save } from "../icons";
import { localDb, type Row } from "../lib/local-db";

Expand Down Expand Up @@ -77,6 +79,12 @@ export function AboutMePage({ userId }: { userId: string }) {
setSaving(false);
};

// Always claimed on this page so Ctrl+S never opens the browser's "Save page" dialog here.
useHotkey("page.save", () => {
if (dirty && !saving) void save();
});
const saveCombo = useHotkeyLabel("page.save");

// Cmd/Ctrl+Enter saves from anywhere on the page.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
Expand Down Expand Up @@ -142,6 +150,7 @@ export function AboutMePage({ userId }: { userId: string }) {
<button
onClick={() => void save()}
disabled={saving}
title={hotkeyHint("Save", saveCombo)}
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-3 rounded-xl transition-colors flex items-center justify-center gap-2 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-5 h-5" />
Expand Down
6 changes: 5 additions & 1 deletion src/components/pages/GoalsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import { useEffect, useRef, useState, type DragEvent, type FormEvent } from "react";
import { Archive, ArchiveRestore, Plus } from "../icons";
import { useHotkey } from "../hotkeys/HotkeysProvider";
import { hotkeyHint } from "../focus/GoalForm";
import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider";
import { localDb, type ChangePayload } from "../lib/local-db";
import { GoalCard, type EditDraft } from "./goals/GoalCard";
import {
Expand Down Expand Up @@ -198,6 +199,8 @@ export function GoalsPage({ userId }: { userId: string }) {

const activeGoals = goals.filter((g) => !g.data.archived);
const archivedGoals = goals.filter((g) => g.data.archived);
useHotkey("list.archived", () => setShowArchived((current) => !current), archivedGoals.length > 0);
const archivedCombo = useHotkeyLabel("list.archived");

const swapActive = (index: number, other: number) => {
const reordered = [...activeGoals];
Expand Down Expand Up @@ -292,6 +295,7 @@ export function GoalsPage({ userId }: { userId: string }) {
<div className="mt-8">
<button
onClick={() => setShowArchived(!showArchived)}
title={hotkeyHint(showArchived ? "Hide archived" : "Show archived", archivedCombo)}
className="text-sm text-gray-400 hover:text-white transition-colors flex items-center gap-2 mx-auto mb-4"
>
<Archive size={16} />
Expand Down
24 changes: 19 additions & 5 deletions src/components/pages/HabitsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { Archive, ArchiveRestore, Calendar, ChevronLeft, ChevronRight, Plus, Trash2 } from "../icons";
import { useHotkey } from "../hotkeys/HotkeysProvider";
import { hotkeyHint } from "../focus/GoalForm";
import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider";
import { localDb } from "../lib/local-db";
import { HabitCard, type HabitEdit } from "./habits/HabitCard";
import { ColorPicker, FIELD_INPUT, FieldLabel, PRIMARY_BUTTON, SECONDARY_BUTTON } from "./habits/HabitFields";
Expand Down Expand Up @@ -118,11 +119,13 @@ function useHabitData(userId: string) {

function ArchivedHabits({ habits, onRestore, onDelete }: { habits: readonly Habit[]; onRestore(habit: Habit): void; onDelete(habit: Habit): void }) {
const [open, setOpen] = useState(false);
useHotkey("list.archived", () => setOpen((current) => !current), habits.length > 0);
const archivedCombo = useHotkeyLabel("list.archived");
if (habits.length === 0) return null;
const iconButton = "text-gray-500 transition-colors p-2 rounded-lg hover:bg-gray-800 opacity-0 group-hover:opacity-100";
return (
<div className="mt-8">
<button onClick={() => setOpen(!open)} className="text-sm text-gray-400 hover:text-white transition-colors flex items-center gap-2 mx-auto mb-4">
<button onClick={() => setOpen(!open)} title={hotkeyHint(open ? "Hide archived" : "Show archived", archivedCombo)} className="text-sm text-gray-400 hover:text-white transition-colors flex items-center gap-2 mx-auto mb-4">
<Archive size={16} />
{open ? "Hide archived habits" : `Show archived habits (${habits.length})`}
</button>
Expand Down Expand Up @@ -356,6 +359,16 @@ export function HabitsPage({ userId }: { userId: string }) {
setViewMode(mode);
setDayOffset(0);
};
// The period bar is only rendered while there are active habits.
const hasPeriodBar = !loading && sortedActive.length > 0;
const previousPeriod = () => setDayOffset(dayOffset + 1);
const nextPeriod = () => dayOffset > 0 && setDayOffset(dayOffset - 1);
useHotkey("list.prev", previousPeriod, hasPeriodBar);
useHotkey("list.next", nextPeriod, hasPeriodBar);
useHotkey("list.range", () => pickView(viewMode === "7days" ? "30days" : "7days"), hasPeriodBar);
const prevCombo = useHotkeyLabel("list.prev");
const nextCombo = useHotkeyLabel("list.next");
const rangeCombo = useHotkeyLabel("list.range");
const viewButton = (mode: ViewMode) => "px-3 py-1 text-sm rounded-md transition-colors " + (viewMode === mode ? "bg-blue-600 text-white" : "text-gray-400 hover:text-white");

if (loading) {
Expand Down Expand Up @@ -391,12 +404,12 @@ export function HabitsPage({ userId }: { userId: string }) {
) : (
<div className="space-y-4">
<div className="flex items-center justify-between bg-gray-900 rounded-xl p-4 border border-gray-800">
<button onClick={() => setDayOffset(dayOffset + 1)} className="p-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors">
<button onClick={previousPeriod} title={hotkeyHint("Previous period", prevCombo)} className="p-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors">
<ChevronLeft className="w-5 h-5" />
</button>
<div className="flex items-center gap-4">
<span className="text-white font-medium">{rangeLabel(dayOffset, viewMode)}</span>
<div className="flex bg-gray-800 rounded-lg p-1">
<div className="flex bg-gray-800 rounded-lg p-1" title={hotkeyHint("Switch 7 / 30 days", rangeCombo)}>
<button onClick={() => pickView("7days")} className={viewButton("7days")}>
7 Days
</button>
Expand All @@ -406,8 +419,9 @@ export function HabitsPage({ userId }: { userId: string }) {
</div>
</div>
<button
onClick={() => dayOffset > 0 && setDayOffset(dayOffset - 1)}
onClick={nextPeriod}
disabled={dayOffset === 0}
title={hotkeyHint("Next period", nextCombo)}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-gray-400"
>
<ChevronRight className="w-5 h-5" />
Expand Down
Loading
Loading