Skip to content
Merged

WIP #33

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
77 changes: 67 additions & 10 deletions frontend/src/Chessrepeat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ import {
FolderCog2Icon,
GraduationCap,
History,
Keyboard,
MessageSquareOff,
MessageSquareText,
NetworkIcon,
} from 'lucide-react';
import SettingsModal from './components/modals/SettingsModal';
import ShortcutsModal from './components/modals/ShortcutsModal';
import { Header } from './components/Header';
import { CollaboratorsPanel } from './components/collaborators/CollaboratorsPanel';
import {
Expand Down Expand Up @@ -64,6 +66,9 @@ import './css/chessrepeat.css';
import { Debug } from './components/Debug';
import { useWebsocket } from './hooks/useWebsocket';
import { useStartup } from './hooks/useStartup';
import { useHotkeys } from './hooks/useHotkeys';
import { useHotkeyStore, comboParts } from './store/hotkeys';
import { firstMove, lastMove, nextMove, prevMove } from './util/navigation';

//TODO we should use chessops library to get promotion role instead of regex..
// unclear if trainingContext stores enough state to get promotion role dynamically
Expand Down Expand Up @@ -128,6 +133,7 @@ export const Chessrepeat = () => {

guess,
makeMove,
setTrainingMethod,
} = useTrainerStore();

const connectedUsers = useTrainerStore((s) => s.connectedUsers);
Expand Down Expand Up @@ -186,6 +192,7 @@ export const Chessrepeat = () => {
const [sounds, setSounds] = useState(SOUNDS);
const [activeMoveId, setActiveMoveId] = useState();
const [settingsOpen, setSettingsOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [fenCopied, setFenCopied] = useState(false);
const [showComments, setShowComments] = useState(true);
const movesContainerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -364,8 +371,8 @@ export const Chessrepeat = () => {
return selectedPath == trainingPath;
};

const prevMove = prevMoveIfExists();
const lastMove = selectedNode ? prevMove : undefined;
const prevMoveUci = prevMoveIfExists();
const lastMoveUci = selectedNode ? prevMoveUci : undefined;

const finishMove = (san: string, meta: MoveMetadata, to: Key) => {
if (!isEditing) {
Expand Down Expand Up @@ -427,6 +434,39 @@ export const Chessrepeat = () => {
updateDueCounts();
};

const copyFen = async () => {
const fen = selectedNode?.data.fen || INITIAL_BOARD_FEN;
if (!fen) return;
await navigator.clipboard.writeText(fen);
setFenCopied(true);
setTimeout(() => setFenCopied(false), 1200);
};

// Same sequence as the Learn / Recall buttons in TrainingControls.
const startTraining = (method: 'learn' | 'recall') => {
setTrainingMethod(method);
setNextTrainablePosition();
updateDueCounts();
};

useHotkeys(
{
firstMove,
prevMove,
nextMove,
lastMove,
modeEdit: () => setTrainingMethod('edit'),
modeLearn: () => startTraining('learn'),
modeRecall: () => startTraining('recall'),
toggleComments: () => setShowComments((v) => !v),
copyFen: () => void copyFen(),
openSettings: () => setSettingsOpen(true),
openShortcuts: () => setShortcutsOpen(true),
},
!pendingPromo && !showingAddToRepertoireMenu,
);
const shortcutsKey = useHotkeyStore((s) => s.bindings.openShortcuts);

//TODO dont try to calculate properties when we haven't initialized the repertoire yet
return (
<MantineProvider>
Expand Down Expand Up @@ -511,7 +551,7 @@ export const Chessrepeat = () => {
orientation={chapter?.trainAs || 'white'}
fen={selectedNode?.data.fen || initial}
turnColor={turn}
lastMove={lastMove}
lastMove={lastMoveUci}
movable={{
free: false,
color: turn,
Expand Down Expand Up @@ -550,6 +590,19 @@ export const Chessrepeat = () => {
</div>
<MobileCommentPopout />
<div className="control-tab">
<button
type="button"
onClick={() => setShortcutsOpen(true)}
className="control-tab-btn settings-btn"
aria-label="Keyboard shortcuts"
title={
shortcutsKey
? `Keyboard shortcuts (${comboParts(shortcutsKey).join('+')})`
: 'Keyboard shortcuts'
}
>
<Keyboard size={18} />
</button>
<button
type="button"
onClick={() => setSettingsOpen(true)}
Expand All @@ -573,6 +626,16 @@ export const Chessrepeat = () => {
</>
)}

{shortcutsOpen && (
<>
<div
className="modal-backdrop modal-backdrop-settings"
onClick={() => setShortcutsOpen(false)}
/>
<ShortcutsModal onClose={() => setShortcutsOpen(false)} />
</>
)}

{/* USER TIP / EXPLORER */}
<div className="area-usertip">
<UserTip />
Expand All @@ -596,13 +659,7 @@ export const Chessrepeat = () => {
{/* copy icon */}
<button
type="button"
onClick={async () => {
const fen = selectedNode?.data.fen || INITIAL_BOARD_FEN;
if (!fen) return;
await navigator.clipboard.writeText(fen);
setFenCopied(true);
setTimeout(() => setFenCopied(false), 1200);
}}
onClick={copyFen}
className={`copy-fen-btn ${fenCopied ? 'is-copied' : ''}`}
aria-label="Copy FEN"
title="Copy FEN"
Expand Down
155 changes: 155 additions & 0 deletions frontend/src/components/modals/ShortcutsModal.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/* Keyboard shortcuts — a thin outer frame holding one card per group, two
across on wider screens. Each row runs label … dotted leader … keycaps. */

.shortcuts-dialog {
position: fixed;
top: 50%;
left: 50%;
z-index: 50;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
width: calc(100% - 2rem);
max-width: 48rem;
max-height: calc(100dvh - 5rem);
margin: 0;
padding: 5px;
border: 1px solid var(--c-line);
border-radius: 0.75rem;
background: var(--c-recessed);
box-shadow: 0 4px 16px rgb(0 0 0 / 0.08);
font-family: inherit;
}

/* ---------- header ---------- */
.shortcuts-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.375rem 0.875rem 0.875rem;
}

.shortcuts-header h2 {
margin: 0;
font-size: 1rem;
font-weight: 400;
color: var(--c-gray-800);
}

.shortcuts-icon-btn {
display: inline-grid;
place-items: center;
width: 1.625rem;
height: 1.625rem;
padding: 0;
border: none;
border-radius: 0.5rem;
background: transparent;
color: var(--c-gray-500);
cursor: pointer;
transition: color 150ms, background-color 150ms;
}

.shortcuts-icon-btn:hover {
color: var(--c-gray-800);
background: var(--c-well);
}

/* ---------- groups ---------- */
.shortcuts-grid {
display: grid;
grid-template-columns: 1fr;
align-items: start;
gap: 5px;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}

@media (min-width: 640px) {
.shortcuts-grid {
grid-template-columns: 1fr 1fr;
}
}

.shortcuts-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--c-hairline);
border-radius: 0.5rem;
background: var(--c-surface);
}

.shortcuts-group h3 {
margin: 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--c-gray-500);
}

.shortcut-list {
display: grid;
gap: 0.375rem;
margin: 0;
padding: 0;
list-style: none;
}

.shortcut-row {
display: flex;
align-items: center;
gap: 0.5rem;
}

.shortcut-label {
font-size: 0.875rem;
font-weight: 400;
white-space: nowrap;
color: var(--c-gray-800);
}

.shortcut-leader {
flex: 1;
min-width: 0.75rem;
height: 1px;
color: var(--c-gray-300);
background-image: linear-gradient(to right, currentcolor 1px, transparent 1px);
background-size: 4px 1px;
background-repeat: repeat-x;
}

/* ---------- keycaps ---------- */
.shortcut-keys {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}

/* a raised cap: the offset shadow is the darker base showing underneath */
.shortcut-keys kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.5rem;
margin-bottom: 3px;
padding: 0 0.5rem;
border-radius: 7px;
background: var(--c-surface);
box-shadow:
inset 0 0 0 1px var(--c-gray-300),
0 3px 0 0 var(--c-gray-200),
0 3px 0 1px var(--c-gray-300);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1;
color: var(--c-gray-600);
}

.shortcut-unbound {
font-size: 0.75rem;
font-style: italic;
color: var(--c-gray-400);
}
58 changes: 58 additions & 0 deletions frontend/src/components/modals/ShortcutsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React, { useEffect } from 'react';
import { XIcon } from 'lucide-react';
import { comboParts, HOTKEY_GROUPS, useHotkeyStore } from '../../store/hotkeys';
import './modals.css';
import './ShortcutsModal.css';

const Keys: React.FC<{ combo: string | null }> = ({ combo }) =>
combo ? (
<span className="shortcut-keys">
{comboParts(combo).map((part, i) => (
<kbd key={i}>{part}</kbd>
))}
</span>
) : (
<span className="shortcut-unbound">Not set</span>
);

const ShortcutsModal: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const bindings = useHotkeyStore((s) => s.bindings);

useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [onClose]);

return (
<dialog open className="shortcuts-dialog" aria-labelledby="shortcuts-title">
<div className="shortcuts-header">
<h2 id="shortcuts-title">Keyboard shortcuts</h2>
<button type="button" className="shortcuts-icon-btn" aria-label="Close" onClick={onClose}>
<XIcon size={15} />
</button>
</div>

<div className="shortcuts-grid">
{HOTKEY_GROUPS.map((group) => (
<section key={group.label} className="shortcuts-group">
<h3>{group.label}</h3>
<ul className="shortcut-list">
{group.actions.map(({ id, label }) => (
<li key={id} className="shortcut-row">
<span className="shortcut-label">{label}</span>
<span className="shortcut-leader" aria-hidden="true" />
<Keys combo={bindings[id]} />
</li>
))}
</ul>
</section>
))}
</div>
</dialog>
);
};

export default ShortcutsModal;
Loading