super-time-tracker-ui 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +28 -0
  3. package/CLAUDE.md +1 -0
  4. package/README.md +36 -0
  5. package/app/api/backup/route.ts +39 -0
  6. package/app/api/entry/delete-bulk/route.ts +53 -0
  7. package/app/api/entry/move/route.ts +46 -0
  8. package/app/api/entry/move-bulk/route.ts +62 -0
  9. package/app/api/entry/route.ts +75 -0
  10. package/app/api/in/route.ts +38 -0
  11. package/app/api/note/route.ts +120 -0
  12. package/app/api/out/route.ts +31 -0
  13. package/app/api/sheet/route.ts +68 -0
  14. package/app/api/state/route.ts +16 -0
  15. package/app/api/tags/route.ts +75 -0
  16. package/app/color-palettes.css +260 -0
  17. package/app/favicon.ico +0 -0
  18. package/app/globals.css +140 -0
  19. package/app/layout.tsx +54 -0
  20. package/app/page.tsx +24 -0
  21. package/app/reporting/page.tsx +11 -0
  22. package/app/settings/data/page.tsx +9 -0
  23. package/app/settings/display/page.tsx +8 -0
  24. package/app/settings/page.tsx +12 -0
  25. package/app/settings/tags/page.tsx +13 -0
  26. package/bin/stt-ui.js +63 -0
  27. package/components/active-entry-panel.tsx +199 -0
  28. package/components/backup-restore-setting.tsx +168 -0
  29. package/components/check-in-form-collapsed-setting.tsx +44 -0
  30. package/components/check-in-form-collapsible.tsx +52 -0
  31. package/components/check-in-form.tsx +89 -0
  32. package/components/checkbox.tsx +75 -0
  33. package/components/checkout-button-group.tsx +73 -0
  34. package/components/chevron-icon.tsx +25 -0
  35. package/components/clear-tag-filters-on-sheet-change-setting.tsx +45 -0
  36. package/components/color-palette-setting.tsx +75 -0
  37. package/components/compact-lists-setting.tsx +42 -0
  38. package/components/confirm-before-checkout-setting.tsx +42 -0
  39. package/components/confirm-destructive-actions-setting.tsx +46 -0
  40. package/components/confirm-dialog-provider.tsx +71 -0
  41. package/components/confirm-dialog.tsx +90 -0
  42. package/components/data-settings-view.tsx +47 -0
  43. package/components/default-reporting-range-setting.tsx +56 -0
  44. package/components/default-reporting-sort-setting.tsx +45 -0
  45. package/components/default-sheet-session-setting.tsx +118 -0
  46. package/components/display-settings-view.tsx +75 -0
  47. package/components/duration-format-setting.tsx +40 -0
  48. package/components/entry-actions-menu.tsx +207 -0
  49. package/components/entry-edit-form.tsx +113 -0
  50. package/components/entry-list-bulk-bar.tsx +128 -0
  51. package/components/entry-list-sort-setting.tsx +41 -0
  52. package/components/entry-list.tsx +336 -0
  53. package/components/entry-notes-list.tsx +211 -0
  54. package/components/entry-tag-filter.tsx +99 -0
  55. package/components/format_datetime_hint.ts +8 -0
  56. package/components/format_time.ts +10 -0
  57. package/components/general-settings-view.tsx +40 -0
  58. package/components/hamburger-icon.tsx +21 -0
  59. package/components/note-edit-form.tsx +77 -0
  60. package/components/note-form.tsx +109 -0
  61. package/components/pencil-icon.tsx +21 -0
  62. package/components/reporting-date-range-picker.tsx +121 -0
  63. package/components/reporting-sort-controls.tsx +53 -0
  64. package/components/reporting-view.tsx +340 -0
  65. package/components/setting-radio-group.tsx +79 -0
  66. package/components/settings-nav.tsx +66 -0
  67. package/components/settings-page-layout.tsx +53 -0
  68. package/components/settings-saved-toast.tsx +57 -0
  69. package/components/sheet-actions-menu.tsx +108 -0
  70. package/components/sheet-sidebar.tsx +196 -0
  71. package/components/tag-autocomplete-input.tsx +183 -0
  72. package/components/tag-filter-mode-setting.tsx +47 -0
  73. package/components/tag-management-view.tsx +290 -0
  74. package/components/theme-mode-setting.tsx +44 -0
  75. package/components/theme-mode-system-listener.tsx +43 -0
  76. package/components/theme_switcher.tsx +38 -0
  77. package/components/time-format-setting.tsx +39 -0
  78. package/components/timer-in-title-setting.tsx +38 -0
  79. package/components/timer-show-seconds-setting.tsx +41 -0
  80. package/components/tracker-active-bar.tsx +76 -0
  81. package/components/tracker-app.tsx +338 -0
  82. package/components/tracker-breadcrumb.tsx +56 -0
  83. package/components/tracker-document-title.tsx +67 -0
  84. package/components/tracker-topbar.tsx +63 -0
  85. package/components/trash-icon.tsx +24 -0
  86. package/components/week-starts-on-setting.tsx +39 -0
  87. package/eslint.config.mjs +18 -0
  88. package/lib/add_note_to_entry.ts +65 -0
  89. package/lib/api_error_response.ts +10 -0
  90. package/lib/apply_accent_color.ts +12 -0
  91. package/lib/apply_color_palette.ts +12 -0
  92. package/lib/apply_compact_lists.ts +9 -0
  93. package/lib/apply_tag_autocomplete_selection.ts +26 -0
  94. package/lib/apply_theme.ts +8 -0
  95. package/lib/build_reporting_stats.ts +55 -0
  96. package/lib/build_resume_description.ts +15 -0
  97. package/lib/check_in_entry.ts +81 -0
  98. package/lib/check_out_entry.ts +75 -0
  99. package/lib/collect_known_tags.ts +22 -0
  100. package/lib/collect_tag_stats.ts +27 -0
  101. package/lib/collect_tags_from_entries.ts +35 -0
  102. package/lib/config.ts +9 -0
  103. package/lib/convert_json_db.ts +49 -0
  104. package/lib/delete_entries.ts +62 -0
  105. package/lib/delete_entry.ts +29 -0
  106. package/lib/delete_note_on_entry.ts +42 -0
  107. package/lib/delete_sheet.ts +30 -0
  108. package/lib/delete_tracker_action.ts +22 -0
  109. package/lib/edit_entry.ts +56 -0
  110. package/lib/edit_note_on_entry.ts +49 -0
  111. package/lib/ensure_dir_exists.ts +22 -0
  112. package/lib/entry_matches_tag_filter.ts +26 -0
  113. package/lib/fetch_tracker_state.ts +15 -0
  114. package/lib/filter_entries_by_tags.ts +20 -0
  115. package/lib/filter_known_tags.ts +20 -0
  116. package/lib/find_all_serialized_active_entries.ts +28 -0
  117. package/lib/find_serialized_active_entry.ts +12 -0
  118. package/lib/find_serialized_active_entry_for_sheet.ts +31 -0
  119. package/lib/find_sheet_with_active_entry.ts +16 -0
  120. package/lib/format_display_tag.ts +6 -0
  121. package/lib/format_duration.ts +45 -0
  122. package/lib/gen_db.ts +43 -0
  123. package/lib/get_active_panel_class_name.ts +20 -0
  124. package/lib/get_average_entry_ms.ts +13 -0
  125. package/lib/get_button_class_name.ts +24 -0
  126. package/lib/get_check_out_confirm_dialog.ts +19 -0
  127. package/lib/get_clipped_entry_duration_ms.ts +18 -0
  128. package/lib/get_compact_lists_snapshot.ts +15 -0
  129. package/lib/get_date_range_ms_from_inputs.ts +31 -0
  130. package/lib/get_delete_entries_confirm_dialog.ts +21 -0
  131. package/lib/get_delete_entry_confirm_dialog.ts +19 -0
  132. package/lib/get_delete_note_confirm_dialog.ts +21 -0
  133. package/lib/get_delete_sheet_confirm_dialog.ts +25 -0
  134. package/lib/get_entry_duration_ms.ts +14 -0
  135. package/lib/get_entry_row_key.ts +8 -0
  136. package/lib/get_initial_preferred_sheet_name.ts +34 -0
  137. package/lib/get_initial_reporting_range_inputs.ts +31 -0
  138. package/lib/get_input_class_name.ts +15 -0
  139. package/lib/get_merge_tags_confirm_dialog.ts +25 -0
  140. package/lib/get_period_range_ms.ts +43 -0
  141. package/lib/get_reporting_date_range_shortcut_inputs.ts +84 -0
  142. package/lib/get_reporting_period_totals.ts +39 -0
  143. package/lib/get_reporting_stats.ts +25 -0
  144. package/lib/get_restore_db_confirm_dialog.ts +14 -0
  145. package/lib/get_running_entry_key.ts +8 -0
  146. package/lib/get_serialized_entries_total_ms.ts +10 -0
  147. package/lib/get_sheet.ts +14 -0
  148. package/lib/get_sheet_report_stats.ts +22 -0
  149. package/lib/get_sheet_report_stats_for_range.ts +46 -0
  150. package/lib/get_sheet_tag_filter_snapshot.ts +22 -0
  151. package/lib/get_sheets_duration_in_range.ts +27 -0
  152. package/lib/get_tag_autocomplete_context.ts +32 -0
  153. package/lib/get_theme_snapshot.ts +16 -0
  154. package/lib/get_tracker_state.ts +67 -0
  155. package/lib/has_string_value.ts +6 -0
  156. package/lib/is_entry_in_day.ts +15 -0
  157. package/lib/is_idle_sheet_report.ts +8 -0
  158. package/lib/is_json_time_tracker_db.ts +14 -0
  159. package/lib/merge_tags_across_db.ts +79 -0
  160. package/lib/migrate_json_db.ts +56 -0
  161. package/lib/migrate_json_db_to_version_three.ts +51 -0
  162. package/lib/migrate_json_db_to_version_two.ts +50 -0
  163. package/lib/move_entries_to_sheet.ts +152 -0
  164. package/lib/move_entry_to_sheet.ts +82 -0
  165. package/lib/normalize_stored_tag.ts +16 -0
  166. package/lib/notify_settings_saved.ts +47 -0
  167. package/lib/parse_default_sheet_session_mode.ts +21 -0
  168. package/lib/parse_entry_from_input.ts +23 -0
  169. package/lib/parse_natural_language_date.ts +23 -0
  170. package/lib/parse_reporting_source_sheets.ts +22 -0
  171. package/lib/partition_sheet_report_stats.ts +30 -0
  172. package/lib/patch_tracker_action.ts +22 -0
  173. package/lib/persist_ui_preference.ts +18 -0
  174. package/lib/post_tracker_action.ts +22 -0
  175. package/lib/preferences/accent_color_preference.ts +21 -0
  176. package/lib/preferences/check_in_form_collapsed_preference.ts +20 -0
  177. package/lib/preferences/clear_tag_filters_on_sheet_change_preference.ts +20 -0
  178. package/lib/preferences/color_palette_preference.ts +21 -0
  179. package/lib/preferences/confirm_before_checkout_preference.ts +20 -0
  180. package/lib/preferences/confirm_destructive_actions_preference.ts +20 -0
  181. package/lib/preferences/default_reporting_range_preference.ts +21 -0
  182. package/lib/preferences/default_reporting_sort_preference.ts +24 -0
  183. package/lib/preferences/duration_format_preference.ts +19 -0
  184. package/lib/preferences/entry_list_sort_preference.ts +21 -0
  185. package/lib/preferences/tag_filter_mode_preference.ts +18 -0
  186. package/lib/preferences/theme_mode_preference.ts +18 -0
  187. package/lib/preferences/time_format_preference.ts +18 -0
  188. package/lib/preferences/timer_in_title_preference.ts +18 -0
  189. package/lib/preferences/timer_show_seconds_preference.ts +19 -0
  190. package/lib/preferences/week_starts_on_preference.ts +19 -0
  191. package/lib/prompt_check_out_at.ts +17 -0
  192. package/lib/prompt_entry_note.ts +14 -0
  193. package/lib/prune_sheet_tag_filter.ts +27 -0
  194. package/lib/read_db.ts +49 -0
  195. package/lib/read_db_backup_contents.ts +22 -0
  196. package/lib/read_document_compact_lists.ts +12 -0
  197. package/lib/read_document_theme.ts +14 -0
  198. package/lib/read_sheet_tag_filter.ts +26 -0
  199. package/lib/read_stored_active_sheet.ts +14 -0
  200. package/lib/read_stored_compact_lists.ts +24 -0
  201. package/lib/read_stored_default_sheet_fixed_name.ts +16 -0
  202. package/lib/read_stored_default_sheet_session_mode.ts +18 -0
  203. package/lib/read_stored_sheet_tag_filters.ts +28 -0
  204. package/lib/read_stored_theme.ts +18 -0
  205. package/lib/rename_sheet.ts +39 -0
  206. package/lib/rename_tag_across_db.ts +19 -0
  207. package/lib/resolve_active_sheet_name.ts +36 -0
  208. package/lib/resolve_session_preferred_sheet.ts +37 -0
  209. package/lib/resolve_theme.ts +18 -0
  210. package/lib/resolve_theme_mode_to_theme.ts +19 -0
  211. package/lib/restore_db_from_uploaded_json.ts +24 -0
  212. package/lib/serialize_entry.ts +27 -0
  213. package/lib/serialize_reporting_source_sheets.ts +19 -0
  214. package/lib/serialize_sheet_entries.ts +18 -0
  215. package/lib/set_accent_color.ts +12 -0
  216. package/lib/set_active_sheet.ts +18 -0
  217. package/lib/set_color_palette.ts +12 -0
  218. package/lib/set_compact_lists.ts +12 -0
  219. package/lib/set_default_sheet_fixed_name.ts +8 -0
  220. package/lib/set_default_sheet_session_mode.ts +11 -0
  221. package/lib/set_sheet_tag_filter.ts +13 -0
  222. package/lib/set_theme_mode.ts +19 -0
  223. package/lib/sheet_tag_filter_snapshots.ts +48 -0
  224. package/lib/sort_serialized_entries.ts +35 -0
  225. package/lib/sort_sheet_report_stats.ts +43 -0
  226. package/lib/subscribe_compact_lists.ts +25 -0
  227. package/lib/subscribe_sheet_tag_filters.ts +28 -0
  228. package/lib/subscribe_theme.ts +23 -0
  229. package/lib/sync_active_sheet_preference.ts +19 -0
  230. package/lib/tags_are_equal.ts +12 -0
  231. package/lib/theme_init_script.ts +11 -0
  232. package/lib/toggle_sheet_tag_filter.ts +28 -0
  233. package/lib/toggle_theme.ts +20 -0
  234. package/lib/types/confirm_dialog.ts +9 -0
  235. package/lib/types/data.ts +16 -0
  236. package/lib/types/generic_data.ts +25 -0
  237. package/lib/types/index.ts +2 -0
  238. package/lib/types/reporting.ts +59 -0
  239. package/lib/types/tag_management.ts +7 -0
  240. package/lib/types/theme.ts +3 -0
  241. package/lib/types/tracker_state.ts +39 -0
  242. package/lib/types/ui_preferences.ts +104 -0
  243. package/lib/types/ui_settings.ts +17 -0
  244. package/lib/ui_preference_store.ts +80 -0
  245. package/lib/ui_settings_init_script.ts +33 -0
  246. package/lib/use_check_in_form_collapsed.ts +18 -0
  247. package/lib/use_clear_tag_filters_on_sheet_change.ts +18 -0
  248. package/lib/use_confirm_before_checkout.ts +18 -0
  249. package/lib/use_confirm_destructive_actions.ts +18 -0
  250. package/lib/use_duration_format.ts +17 -0
  251. package/lib/use_entry_list_sort.ts +17 -0
  252. package/lib/use_tag_filter_mode.ts +17 -0
  253. package/lib/use_time_format.ts +17 -0
  254. package/lib/use_timer_in_title.ts +18 -0
  255. package/lib/use_timer_show_seconds.ts +18 -0
  256. package/lib/use_week_starts_on.ts +17 -0
  257. package/lib/validate_entry_times.ts +12 -0
  258. package/lib/week_starts_on_to_index.ts +8 -0
  259. package/lib/write_active_sheet_preference.ts +28 -0
  260. package/lib/write_db.ts +20 -0
  261. package/lib/write_sheet_tag_filter.ts +20 -0
  262. package/lib/write_stored_compact_lists.ts +15 -0
  263. package/lib/write_stored_default_sheet_fixed_name.ts +28 -0
  264. package/lib/write_stored_default_sheet_session_mode.ts +24 -0
  265. package/lib/write_stored_sheet_tag_filters.ts +18 -0
  266. package/lib/write_stored_theme.ts +12 -0
  267. package/next.config.ts +7 -0
  268. package/package.json +96 -0
  269. package/pnpm-workspace.yaml +7 -0
  270. package/postcss.config.mjs +7 -0
  271. package/public/file.svg +1 -0
  272. package/public/globe.svg +1 -0
  273. package/public/next.svg +1 -0
  274. package/public/vercel.svg +1 -0
  275. package/public/window.svg +1 -0
  276. package/tsconfig.json +34 -0
@@ -0,0 +1,196 @@
1
+ 'use client'
2
+
3
+ import { type FormEvent, useState } from 'react'
4
+
5
+ import { use_confirm_dialog } from '@/components/confirm-dialog-provider'
6
+ import { SheetActionsMenu } from '@/components/sheet-actions-menu'
7
+ import { get_delete_sheet_confirm_dialog } from '@/lib/get_delete_sheet_confirm_dialog'
8
+ import { use_confirm_destructive_actions } from '@/lib/use_confirm_destructive_actions'
9
+ import { get_button_class_name } from '@/lib/get_button_class_name'
10
+ import { get_input_class_name } from '@/lib/get_input_class_name'
11
+ import { type SerializedSheet } from '@/lib/types/tracker_state'
12
+
13
+ interface SheetSidebarProps {
14
+ sheets: SerializedSheet[]
15
+ db_path: string
16
+ on_select: (name: string) => void
17
+ on_create: (name: string) => void
18
+ on_rename: (name: string, new_name: string) => void
19
+ on_delete: (name: string) => void
20
+ is_pending: boolean
21
+ }
22
+
23
+ /**
24
+ * Lists sheets and supports switching, renaming, or creating new ones.
25
+ */
26
+ export function SheetSidebar({
27
+ sheets,
28
+ db_path,
29
+ on_select,
30
+ on_create,
31
+ on_rename,
32
+ on_delete,
33
+ is_pending,
34
+ }: SheetSidebarProps) {
35
+ const { confirm } = use_confirm_dialog()
36
+ const confirm_destructive_actions = use_confirm_destructive_actions()
37
+ const can_delete_sheet = sheets.length > 1
38
+ const [new_sheet_name, set_new_sheet_name] = useState('')
39
+ const [editing_sheet_name, set_editing_sheet_name] = useState<string | null>(
40
+ null,
41
+ )
42
+ const [edited_sheet_name, set_edited_sheet_name] = useState('')
43
+
44
+ const handle_create = (event: FormEvent<HTMLFormElement>): void => {
45
+ event.preventDefault()
46
+ const trimmed = new_sheet_name.trim()
47
+
48
+ if (trimmed.length === 0) {
49
+ return
50
+ }
51
+
52
+ on_create(trimmed)
53
+ set_new_sheet_name('')
54
+ }
55
+
56
+ const start_rename = (sheet_name: string): void => {
57
+ set_editing_sheet_name(sheet_name)
58
+ set_edited_sheet_name(sheet_name)
59
+ }
60
+
61
+ const cancel_rename = (): void => {
62
+ set_editing_sheet_name(null)
63
+ set_edited_sheet_name('')
64
+ }
65
+
66
+ const handle_rename = (event: FormEvent<HTMLFormElement>): void => {
67
+ event.preventDefault()
68
+
69
+ if (editing_sheet_name === null) {
70
+ return
71
+ }
72
+
73
+ const trimmed = edited_sheet_name.trim()
74
+
75
+ if (trimmed.length === 0 || trimmed === editing_sheet_name) {
76
+ cancel_rename()
77
+ return
78
+ }
79
+
80
+ on_rename(editing_sheet_name, trimmed)
81
+ cancel_rename()
82
+ }
83
+
84
+ return (
85
+ <aside className="flex min-w-0 flex-col gap-3 max-[860px]:min-w-0">
86
+ <h2 className="m-0 text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-muted">
87
+ Sheets
88
+ </h2>
89
+ <ul className="m-0 flex min-h-0 flex-1 list-none flex-col gap-1.5 p-0">
90
+ {sheets.map((sheet) => (
91
+ <li key={sheet.name} className="min-w-0">
92
+ {editing_sheet_name === sheet.name ? (
93
+ <form className="flex w-full min-w-0 flex-col gap-1.5" onSubmit={handle_rename}>
94
+ <input
95
+ className={get_input_class_name('compact')}
96
+ value={edited_sheet_name}
97
+ onChange={(event) => set_edited_sheet_name(event.target.value)}
98
+ disabled={is_pending}
99
+ autoFocus
100
+ />
101
+ <div className="flex gap-1.5">
102
+ <button
103
+ type="submit"
104
+ className={`${get_button_class_name('ghost')} flex-1`}
105
+ disabled={
106
+ is_pending || edited_sheet_name.trim().length === 0
107
+ }
108
+ >
109
+ Save
110
+ </button>
111
+ <button
112
+ type="button"
113
+ className={`${get_button_class_name('ghost')} flex-1`}
114
+ disabled={is_pending}
115
+ onClick={cancel_rename}
116
+ >
117
+ Cancel
118
+ </button>
119
+ </div>
120
+ </form>
121
+ ) : (
122
+ <div className="flex min-w-0 items-stretch gap-1">
123
+ <button
124
+ type="button"
125
+ className="flex min-w-0 flex-1 cursor-pointer items-center justify-between gap-2 rounded-md border border-transparent bg-transparent px-2.5 py-2 text-left transition-[background-color,color] duration-150 hover:bg-surface-hover"
126
+ disabled={is_pending}
127
+ onClick={() => on_select(sheet.name)}
128
+ >
129
+ <span
130
+ className={`min-w-0 flex-1 truncate font-semibold ${
131
+ sheet.isActive || sheet.hasActiveEntry ? 'text-accent' : ''
132
+ }`}
133
+ >
134
+ {sheet.name}
135
+ </span>
136
+ <span
137
+ className={`shrink-0 text-xs whitespace-nowrap ${
138
+ sheet.hasActiveEntry ? 'text-accent' : 'text-muted'
139
+ }`}
140
+ >
141
+ {sheet.hasActiveEntry
142
+ ? '● active'
143
+ : `${sheet.entryCount} entries`}
144
+ </span>
145
+ </button>
146
+ <SheetActionsMenu
147
+ sheet_name={sheet.name}
148
+ is_pending={is_pending}
149
+ can_delete={can_delete_sheet}
150
+ on_rename={() => start_rename(sheet.name)}
151
+ on_delete={async () => {
152
+ const confirmed = confirm_destructive_actions
153
+ ? await confirm(
154
+ get_delete_sheet_confirm_dialog(
155
+ sheet.name,
156
+ sheet.entryCount,
157
+ sheet.hasActiveEntry,
158
+ ),
159
+ )
160
+ : true
161
+
162
+ if (confirmed) {
163
+ on_delete(sheet.name)
164
+ }
165
+ }}
166
+ />
167
+ </div>
168
+ )}
169
+ </li>
170
+ ))}
171
+ </ul>
172
+ <form className="flex w-full min-w-0 flex-col gap-2" onSubmit={handle_create}>
173
+ <input
174
+ className={get_input_class_name('compact')}
175
+ value={new_sheet_name}
176
+ onChange={(event) => set_new_sheet_name(event.target.value)}
177
+ placeholder="New sheet name"
178
+ disabled={is_pending}
179
+ />
180
+ <button
181
+ type="submit"
182
+ className={get_button_class_name('ghost')}
183
+ disabled={is_pending || new_sheet_name.trim().length === 0}
184
+ >
185
+ Add
186
+ </button>
187
+ </form>
188
+ <p
189
+ className="mt-auto shrink-0 overflow-wrap-anywhere border-t border-panel-border pt-3 font-mono text-[0.65rem] leading-snug text-muted"
190
+ title={db_path}
191
+ >
192
+ {db_path}
193
+ </p>
194
+ </aside>
195
+ )
196
+ }
@@ -0,0 +1,183 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type KeyboardEvent,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ } from 'react'
10
+
11
+ import { apply_tag_autocomplete_selection } from '@/lib/apply_tag_autocomplete_selection'
12
+ import { filter_known_tags } from '@/lib/filter_known_tags'
13
+ import { format_display_tag } from '@/lib/format_display_tag'
14
+ import { get_tag_autocomplete_context } from '@/lib/get_tag_autocomplete_context'
15
+ import { get_input_class_name } from '@/lib/get_input_class_name'
16
+
17
+ interface TagAutocompleteInputProps {
18
+ id: string
19
+ value: string
20
+ known_tags: string[]
21
+ placeholder?: string
22
+ disabled?: boolean
23
+ autoFocus?: boolean
24
+ on_change: (value: string) => void
25
+ }
26
+
27
+ const option_class =
28
+ 'block w-full cursor-pointer rounded-[0.45rem] border-0 bg-transparent px-2.5 py-1.5 text-left font-inherit text-[0.85rem] text-inherit hover:bg-surface-hover'
29
+
30
+ /**
31
+ * Text input that suggests existing @tags while typing.
32
+ */
33
+ export function TagAutocompleteInput({
34
+ id,
35
+ value,
36
+ known_tags,
37
+ placeholder,
38
+ disabled = false,
39
+ autoFocus = false,
40
+ on_change,
41
+ }: TagAutocompleteInputProps) {
42
+ const input_ref = useRef<HTMLInputElement>(null)
43
+ const list_ref = useRef<HTMLUListElement>(null)
44
+ const pending_cursor_ref = useRef<number | null>(null)
45
+ const [cursor_index, set_cursor_index] = useState(0)
46
+ const [highlighted_index, set_highlighted_index] = useState(0)
47
+
48
+ const context = get_tag_autocomplete_context(value, cursor_index)
49
+ const suggestions =
50
+ context === null ? [] : filter_known_tags(known_tags, context.query)
51
+ const is_open = context !== null && suggestions.length > 0 && !disabled
52
+
53
+ useLayoutEffect(() => {
54
+ if (pending_cursor_ref.current === null || input_ref.current === null) {
55
+ return
56
+ }
57
+
58
+ input_ref.current.setSelectionRange(
59
+ pending_cursor_ref.current,
60
+ pending_cursor_ref.current,
61
+ )
62
+ set_cursor_index(pending_cursor_ref.current)
63
+ pending_cursor_ref.current = null
64
+ }, [value])
65
+
66
+ useEffect(() => {
67
+ set_highlighted_index(0)
68
+ }, [value, cursor_index, suggestions.length])
69
+
70
+ const update_cursor_from_input = (): void => {
71
+ set_cursor_index(input_ref.current?.selectionStart ?? value.length)
72
+ }
73
+
74
+ const close_autocomplete = (): void => {
75
+ set_highlighted_index(0)
76
+ }
77
+
78
+ const apply_tag = (tag: string): void => {
79
+ if (context === null) {
80
+ return
81
+ }
82
+
83
+ const { next_text, next_cursor } = apply_tag_autocomplete_selection(
84
+ value,
85
+ context,
86
+ tag,
87
+ )
88
+
89
+ pending_cursor_ref.current = next_cursor
90
+ on_change(next_text)
91
+ close_autocomplete()
92
+ }
93
+
94
+ const handle_key_down = (event: KeyboardEvent<HTMLInputElement>): void => {
95
+ if (!is_open) {
96
+ return
97
+ }
98
+
99
+ if (event.key === 'ArrowDown') {
100
+ event.preventDefault()
101
+ set_highlighted_index((index) => (index + 1) % suggestions.length)
102
+ return
103
+ }
104
+
105
+ if (event.key === 'ArrowUp') {
106
+ event.preventDefault()
107
+ set_highlighted_index(
108
+ (index) => (index - 1 + suggestions.length) % suggestions.length,
109
+ )
110
+ return
111
+ }
112
+
113
+ if (event.key === 'Enter' || event.key === 'Tab') {
114
+ event.preventDefault()
115
+ const selected = suggestions[highlighted_index]
116
+
117
+ if (selected !== undefined) {
118
+ apply_tag(selected)
119
+ }
120
+
121
+ return
122
+ }
123
+
124
+ if (event.key === 'Escape') {
125
+ event.preventDefault()
126
+ close_autocomplete()
127
+ }
128
+ }
129
+
130
+ return (
131
+ <div className="relative min-w-0">
132
+ <input
133
+ ref={input_ref}
134
+ id={id}
135
+ type="text"
136
+ className={get_input_class_name()}
137
+ value={value}
138
+ placeholder={placeholder}
139
+ disabled={disabled}
140
+ autoFocus={autoFocus}
141
+ autoComplete="off"
142
+ aria-autocomplete="list"
143
+ aria-expanded={is_open}
144
+ aria-controls={is_open ? `${id}-tag-suggestions` : undefined}
145
+ onChange={(event) => {
146
+ on_change(event.target.value)
147
+ set_cursor_index(event.target.selectionStart ?? event.target.value.length)
148
+ }}
149
+ onClick={update_cursor_from_input}
150
+ onKeyUp={update_cursor_from_input}
151
+ onKeyDown={handle_key_down}
152
+ onBlur={() => {
153
+ window.setTimeout(close_autocomplete, 120)
154
+ }}
155
+ />
156
+ {is_open ? (
157
+ <ul
158
+ ref={list_ref}
159
+ id={`${id}-tag-suggestions`}
160
+ role="listbox"
161
+ className="absolute left-0 right-0 top-full z-20 mt-1 max-h-48 list-none overflow-y-auto rounded-md border border-panel-border bg-panel p-1.5 shadow-md"
162
+ >
163
+ {suggestions.map((tag, index) => (
164
+ <li key={tag} role="option" aria-selected={index === highlighted_index}>
165
+ <button
166
+ type="button"
167
+ className={`${option_class} ${
168
+ index === highlighted_index ? 'bg-surface-hover' : ''
169
+ }`}
170
+ onMouseDown={(event) => {
171
+ event.preventDefault()
172
+ apply_tag(tag)
173
+ }}
174
+ >
175
+ {format_display_tag(tag)}
176
+ </button>
177
+ </li>
178
+ ))}
179
+ </ul>
180
+ ) : null}
181
+ </div>
182
+ )
183
+ }
@@ -0,0 +1,47 @@
1
+ 'use client'
2
+
3
+ import { useSyncExternalStore } from 'react'
4
+
5
+ import { SettingRadioGroup } from '@/components/setting-radio-group'
6
+ import { tag_filter_mode_preference } from '@/lib/preferences/tag_filter_mode_preference'
7
+ import { persist_ui_preference } from '@/lib/persist_ui_preference'
8
+ import { type TagFilterMode } from '@/lib/types/ui_preferences'
9
+
10
+ const options: { value: TagFilterMode; label: string; description: string }[] = [
11
+ {
12
+ value: 'all',
13
+ label: 'Match all tags',
14
+ description: 'Entry must include every selected tag.',
15
+ },
16
+ {
17
+ value: 'any',
18
+ label: 'Match any tag',
19
+ description: 'Entry can include any one of the selected tags.',
20
+ },
21
+ ]
22
+
23
+ const set_tag_filter_mode = (value: TagFilterMode): void => {
24
+ persist_ui_preference(tag_filter_mode_preference, value)
25
+ }
26
+
27
+ /**
28
+ * Setting: how multiple tag filters are combined.
29
+ */
30
+ export function TagFilterModeSetting() {
31
+ const value = useSyncExternalStore(
32
+ tag_filter_mode_preference.subscribe,
33
+ tag_filter_mode_preference.get_snapshot,
34
+ tag_filter_mode_preference.get_server_snapshot,
35
+ )
36
+
37
+ return (
38
+ <SettingRadioGroup<TagFilterMode>
39
+ name="tag-filter-mode"
40
+ legend="Tag filter mode"
41
+ description="How entries match when multiple tags are selected on a sheet."
42
+ value={value}
43
+ options={options}
44
+ on_change={set_tag_filter_mode}
45
+ />
46
+ )
47
+ }