Skip to content

Commit d9cce1d

Browse files
enkoclaude
andcommitted
feat(frontend): Open linked friends from encounter detail with o
Pressing o on the encounter detail page did nothing because no open-mode config existed for the /encounters/:id route. Add an encounter-friend open mode mirroring the collective member pattern: a store of the encounter's linked friend IDs, keyboard hint badges on each friend chip, a hint panel, and a help-dialog entry. Pressing o then a hint number now navigates to the corresponding friend. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5c31fa7 commit d9cce1d

6 files changed

Lines changed: 88 additions & 12 deletions

File tree

apps/frontend/src/lib/components/encounters/encounter-detail.svelte

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@ import { goto } from '$app/navigation';
77
import MarkdownView from '$lib/editor/markdown-view.svelte';
88
import { createI18n } from '$lib/i18n/index.js';
99
import { encounters } from '$lib/stores/encounters';
10+
import {
11+
isOpenEncounterFriendModeActive,
12+
openEncounterFriendModePrefix,
13+
visibleEncounterFriendIds,
14+
} from '$lib/stores/ui';
1015
import type { Encounter } from '$shared';
1116
import FriendAvatar from '../friends/friend-avatar.svelte';
17+
import KeyboardHintBadge from '../keyboard-hint-badge.svelte';
1218
import { encounterDisplayTitle, encounterTypeLabel } from './encounter-display';
1319
import EncounterTypeIcon from './encounter-type-icon.svelte';
1420
@@ -26,6 +32,11 @@ let showDeleteConfirm = $state(false);
2632
2733
let displayTitle = $derived(encounterDisplayTitle($i18n.t, encounter));
2834
35+
// Track visible friend IDs for keyboard open mode (o)
36+
$effect(() => {
37+
visibleEncounterFriendIds.set(encounter.friends.map((f) => f.id));
38+
});
39+
2940
function formatDate(dateStr: string): string {
3041
const date = new Date(dateStr);
3142
return date.toLocaleDateString(undefined, {
@@ -112,18 +123,21 @@ async function handleDelete() {
112123
<span class="text-sm font-body font-normal text-white/80">({encounter.friends.length})</span>
113124
</h2>
114125
<div class="flex flex-wrap gap-3 p-3 bg-gray-50 rounded-lg">
115-
{#each encounter.friends as friend (friend.id)}
116-
<a
117-
href="/friends/{friend.id}"
118-
class="inline-flex items-center gap-2 bg-white border border-gray-200 px-3 py-2 rounded-lg hover:border-forest transition-colors"
119-
>
120-
<FriendAvatar
121-
displayName={friend.displayName}
122-
photoUrl={friend.photoUrl}
123-
size="sm"
124-
/>
125-
<span class="font-body text-sm text-gray-900">{friend.displayName}</span>
126-
</a>
126+
{#each encounter.friends as friend, index (friend.id)}
127+
<div class="relative">
128+
<KeyboardHintBadge {index} isActive={$isOpenEncounterFriendModeActive} prefix={$openEncounterFriendModePrefix} variant="card" />
129+
<a
130+
href="/friends/{friend.id}"
131+
class="inline-flex items-center gap-2 bg-white border border-gray-200 px-3 py-2 rounded-lg hover:border-forest transition-colors"
132+
>
133+
<FriendAvatar
134+
displayName={friend.displayName}
135+
photoUrl={friend.photoUrl}
136+
size="sm"
137+
/>
138+
<span class="font-body text-sm text-gray-900">{friend.displayName}</span>
139+
</a>
140+
</div>
127141
{/each}
128142
</div>
129143
</section>

apps/frontend/src/lib/shortcuts/components/help-dialog.svelte

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,24 @@ const i18n = createI18n();
247247
<span class="text-gray-700">{$i18n.t('shortcuts.help.editEncounter')}</span>
248248
<kbd class="px-2 py-1 bg-gray-100 border border-gray-300 rounded text-sm font-mono">e</kbd>
249249
</div>
250+
<div class="flex justify-between items-center">
251+
<span class="text-gray-700">{$i18n.t('shortcuts.help.openItem19', { item: $i18n.t('shortcuts.help.friend') })}</span>
252+
<div class="flex gap-1">
253+
<kbd class="px-2 py-1 bg-gray-100 border border-gray-300 rounded text-sm font-mono">o</kbd>
254+
<span class="text-gray-400">{$i18n.t('shortcuts.help.then')}</span>
255+
<kbd class="px-2 py-1 bg-gray-100 border border-gray-300 rounded text-sm font-mono">1-9</kbd>
256+
</div>
257+
</div>
258+
<div class="flex justify-between items-center">
259+
<span class="text-gray-700">{$i18n.t('shortcuts.help.openItem10', { item: $i18n.t('shortcuts.help.friend') })}</span>
260+
<div class="flex gap-1">
261+
<kbd class="px-2 py-1 bg-gray-100 border border-gray-300 rounded text-sm font-mono">o</kbd>
262+
<span class="text-gray-400">{$i18n.t('shortcuts.help.then')}</span>
263+
<kbd class="px-2 py-1 bg-gray-100 border border-gray-300 rounded text-sm font-mono">a-z</kbd>
264+
<span class="text-gray-400">{$i18n.t('shortcuts.help.then')}</span>
265+
<kbd class="px-2 py-1 bg-gray-100 border border-gray-300 rounded text-sm font-mono">1-9</kbd>
266+
</div>
267+
</div>
250268
</div>
251269
</div>
252270
{/if}

apps/frontend/src/lib/shortcuts/config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import {
22
FILTER_CATEGORY_KEYS,
33
isOpenCollectiveModeActive,
4+
isOpenEncounterFriendModeActive,
45
isOpenEncounterModeActive,
56
isOpenFriendLinkModeActive,
67
isOpenMemberModeActive,
78
isOpenModeActive,
89
openCollectiveModePrefix,
10+
openEncounterFriendModePrefix,
911
openEncounterModePrefix,
1012
openFriendLinkModePrefix,
1113
openMemberModePrefix,
1214
openModePrefix,
1315
visibleCollectiveIds,
16+
visibleEncounterFriendIds,
1417
visibleEncounterIds,
1518
visibleFriendDetailLinks,
1619
visibleFriendIds,
@@ -126,6 +129,14 @@ export const OPEN_MODE_CONFIGS: OpenModeConfig[] = [
126129
basePath: '/friends',
127130
modeName: 'member',
128131
},
132+
{
133+
routeMatch: /^\/encounters\/[^/]+$/,
134+
itemIdsStore: visibleEncounterFriendIds,
135+
prefixStore: openEncounterFriendModePrefix,
136+
activeStore: isOpenEncounterFriendModeActive,
137+
basePath: '/friends',
138+
modeName: 'encounterFriend',
139+
},
129140
{
130141
routeMatch: '/friends',
131142
itemIdsStore: visibleFriendIds,

apps/frontend/src/lib/shortcuts/handlers/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
isDeleteCircleModeActive,
66
isEditCircleModeActive,
77
isOpenCollectiveModeActive,
8+
isOpenEncounterFriendModeActive,
89
isOpenEncounterModeActive,
910
isOpenFriendLinkModeActive,
1011
isOpenMemberModeActive,
@@ -133,6 +134,12 @@ export function createKeydownHandler(callbacks: KeydownHandlerCallbacks) {
133134
isOpenFriendLinkModeActive.set(true);
134135
return;
135136
}
137+
if (pathname.match(/^\/encounters\/[^/]+$/) && !pathname.endsWith('/new')) {
138+
e.preventDefault();
139+
callbacks.setPendingKey('o');
140+
isOpenEncounterFriendModeActive.set(true);
141+
return;
142+
}
136143
}
137144

138145
// Start filter sequence (only on friends list page)

apps/frontend/src/lib/shortcuts/keyboard-shortcuts.svelte

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import {
1010
isEditCircleModeActive,
1111
isFilterModeActive,
1212
isOpenCollectiveModeActive,
13+
isOpenEncounterFriendModeActive,
1314
isOpenEncounterModeActive,
1415
isOpenFriendLinkModeActive,
1516
isOpenMemberModeActive,
1617
isOpenModeActive,
1718
openCollectiveModePrefix,
19+
openEncounterFriendModePrefix,
1820
openEncounterModePrefix,
1921
openFriendLinkModePrefix,
2022
openMemberModePrefix,
@@ -67,6 +69,8 @@ function clearPending() {
6769
openMemberModePrefix.set(null);
6870
isOpenFriendLinkModeActive.set(false);
6971
openFriendLinkModePrefix.set(null);
72+
isOpenEncounterFriendModeActive.set(false);
73+
openEncounterFriendModePrefix.set(null);
7074
}
7175
7276
// The handler is recreated when showHelp changes so guards get fresh state
@@ -120,6 +124,8 @@ const handleKeydown = $derived(
120124
<HintModePanel title={$i18n.t('shortcuts.panels.openCollective')} prefix={$openCollectiveModePrefix} itemDescription={$i18n.t('shortcuts.items.collective')} />
121125
{:else if pendingKey === 'o' && isOnFriendDetailPage}
122126
<HintModePanel title={$i18n.t('shortcuts.panels.openLink')} prefix={$openFriendLinkModePrefix} itemDescription={$i18n.t('shortcuts.items.link')} />
127+
{:else if pendingKey === 'o' && isOnEncounterDetailPage}
128+
<HintModePanel title={$i18n.t('shortcuts.panels.openFriend')} prefix={$openEncounterFriendModePrefix} itemDescription={$i18n.t('shortcuts.items.friend')} />
123129
{:else if pendingKey === 'f'}
124130
<FilterPanel />
125131
{:else if pendingKey === 'e' && isOnCirclesPage}

apps/frontend/src/lib/stores/ui.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,26 @@ export const openEncounterModePrefix = writable<string | null>(null);
214214
*/
215215
export const visibleEncounterIds = writable<string[]>([]);
216216

217+
// =============================================================================
218+
// Encounter Friend Open Mode State (keyboard-driven friend opening on encounter detail)
219+
// =============================================================================
220+
221+
/**
222+
* Tracks if "open encounter friend mode" is active for quick keyboard navigation
223+
* to the friends linked on an encounter detail page
224+
*/
225+
export const isOpenEncounterFriendModeActive = writable(false);
226+
227+
/**
228+
* Tracks the current letter prefix in open encounter friend mode (e.g., 'a', 'b', 'c')
229+
*/
230+
export const openEncounterFriendModePrefix = writable<string | null>(null);
231+
232+
/**
233+
* List of friend IDs linked to the current encounter (for open mode navigation)
234+
*/
235+
export const visibleEncounterFriendIds = writable<string[]>([]);
236+
217237
/**
218238
* Mapping of keyboard keys to filter category field names
219239
*/

0 commit comments

Comments
 (0)