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.
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +28 -0
- package/CLAUDE.md +1 -0
- package/README.md +36 -0
- package/app/api/backup/route.ts +39 -0
- package/app/api/entry/delete-bulk/route.ts +53 -0
- package/app/api/entry/move/route.ts +46 -0
- package/app/api/entry/move-bulk/route.ts +62 -0
- package/app/api/entry/route.ts +75 -0
- package/app/api/in/route.ts +38 -0
- package/app/api/note/route.ts +120 -0
- package/app/api/out/route.ts +31 -0
- package/app/api/sheet/route.ts +68 -0
- package/app/api/state/route.ts +16 -0
- package/app/api/tags/route.ts +75 -0
- package/app/color-palettes.css +260 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +140 -0
- package/app/layout.tsx +54 -0
- package/app/page.tsx +24 -0
- package/app/reporting/page.tsx +11 -0
- package/app/settings/data/page.tsx +9 -0
- package/app/settings/display/page.tsx +8 -0
- package/app/settings/page.tsx +12 -0
- package/app/settings/tags/page.tsx +13 -0
- package/bin/stt-ui.js +63 -0
- package/components/active-entry-panel.tsx +199 -0
- package/components/backup-restore-setting.tsx +168 -0
- package/components/check-in-form-collapsed-setting.tsx +44 -0
- package/components/check-in-form-collapsible.tsx +52 -0
- package/components/check-in-form.tsx +89 -0
- package/components/checkbox.tsx +75 -0
- package/components/checkout-button-group.tsx +73 -0
- package/components/chevron-icon.tsx +25 -0
- package/components/clear-tag-filters-on-sheet-change-setting.tsx +45 -0
- package/components/color-palette-setting.tsx +75 -0
- package/components/compact-lists-setting.tsx +42 -0
- package/components/confirm-before-checkout-setting.tsx +42 -0
- package/components/confirm-destructive-actions-setting.tsx +46 -0
- package/components/confirm-dialog-provider.tsx +71 -0
- package/components/confirm-dialog.tsx +90 -0
- package/components/data-settings-view.tsx +47 -0
- package/components/default-reporting-range-setting.tsx +56 -0
- package/components/default-reporting-sort-setting.tsx +45 -0
- package/components/default-sheet-session-setting.tsx +118 -0
- package/components/display-settings-view.tsx +75 -0
- package/components/duration-format-setting.tsx +40 -0
- package/components/entry-actions-menu.tsx +207 -0
- package/components/entry-edit-form.tsx +113 -0
- package/components/entry-list-bulk-bar.tsx +128 -0
- package/components/entry-list-sort-setting.tsx +41 -0
- package/components/entry-list.tsx +336 -0
- package/components/entry-notes-list.tsx +211 -0
- package/components/entry-tag-filter.tsx +99 -0
- package/components/format_datetime_hint.ts +8 -0
- package/components/format_time.ts +10 -0
- package/components/general-settings-view.tsx +40 -0
- package/components/hamburger-icon.tsx +21 -0
- package/components/note-edit-form.tsx +77 -0
- package/components/note-form.tsx +109 -0
- package/components/pencil-icon.tsx +21 -0
- package/components/reporting-date-range-picker.tsx +121 -0
- package/components/reporting-sort-controls.tsx +53 -0
- package/components/reporting-view.tsx +340 -0
- package/components/setting-radio-group.tsx +79 -0
- package/components/settings-nav.tsx +66 -0
- package/components/settings-page-layout.tsx +53 -0
- package/components/settings-saved-toast.tsx +57 -0
- package/components/sheet-actions-menu.tsx +108 -0
- package/components/sheet-sidebar.tsx +196 -0
- package/components/tag-autocomplete-input.tsx +183 -0
- package/components/tag-filter-mode-setting.tsx +47 -0
- package/components/tag-management-view.tsx +290 -0
- package/components/theme-mode-setting.tsx +44 -0
- package/components/theme-mode-system-listener.tsx +43 -0
- package/components/theme_switcher.tsx +38 -0
- package/components/time-format-setting.tsx +39 -0
- package/components/timer-in-title-setting.tsx +38 -0
- package/components/timer-show-seconds-setting.tsx +41 -0
- package/components/tracker-active-bar.tsx +76 -0
- package/components/tracker-app.tsx +338 -0
- package/components/tracker-breadcrumb.tsx +56 -0
- package/components/tracker-document-title.tsx +67 -0
- package/components/tracker-topbar.tsx +63 -0
- package/components/trash-icon.tsx +24 -0
- package/components/week-starts-on-setting.tsx +39 -0
- package/eslint.config.mjs +18 -0
- package/lib/add_note_to_entry.ts +65 -0
- package/lib/api_error_response.ts +10 -0
- package/lib/apply_accent_color.ts +12 -0
- package/lib/apply_color_palette.ts +12 -0
- package/lib/apply_compact_lists.ts +9 -0
- package/lib/apply_tag_autocomplete_selection.ts +26 -0
- package/lib/apply_theme.ts +8 -0
- package/lib/build_reporting_stats.ts +55 -0
- package/lib/build_resume_description.ts +15 -0
- package/lib/check_in_entry.ts +81 -0
- package/lib/check_out_entry.ts +75 -0
- package/lib/collect_known_tags.ts +22 -0
- package/lib/collect_tag_stats.ts +27 -0
- package/lib/collect_tags_from_entries.ts +35 -0
- package/lib/config.ts +9 -0
- package/lib/convert_json_db.ts +49 -0
- package/lib/delete_entries.ts +62 -0
- package/lib/delete_entry.ts +29 -0
- package/lib/delete_note_on_entry.ts +42 -0
- package/lib/delete_sheet.ts +30 -0
- package/lib/delete_tracker_action.ts +22 -0
- package/lib/edit_entry.ts +56 -0
- package/lib/edit_note_on_entry.ts +49 -0
- package/lib/ensure_dir_exists.ts +22 -0
- package/lib/entry_matches_tag_filter.ts +26 -0
- package/lib/fetch_tracker_state.ts +15 -0
- package/lib/filter_entries_by_tags.ts +20 -0
- package/lib/filter_known_tags.ts +20 -0
- package/lib/find_all_serialized_active_entries.ts +28 -0
- package/lib/find_serialized_active_entry.ts +12 -0
- package/lib/find_serialized_active_entry_for_sheet.ts +31 -0
- package/lib/find_sheet_with_active_entry.ts +16 -0
- package/lib/format_display_tag.ts +6 -0
- package/lib/format_duration.ts +45 -0
- package/lib/gen_db.ts +43 -0
- package/lib/get_active_panel_class_name.ts +20 -0
- package/lib/get_average_entry_ms.ts +13 -0
- package/lib/get_button_class_name.ts +24 -0
- package/lib/get_check_out_confirm_dialog.ts +19 -0
- package/lib/get_clipped_entry_duration_ms.ts +18 -0
- package/lib/get_compact_lists_snapshot.ts +15 -0
- package/lib/get_date_range_ms_from_inputs.ts +31 -0
- package/lib/get_delete_entries_confirm_dialog.ts +21 -0
- package/lib/get_delete_entry_confirm_dialog.ts +19 -0
- package/lib/get_delete_note_confirm_dialog.ts +21 -0
- package/lib/get_delete_sheet_confirm_dialog.ts +25 -0
- package/lib/get_entry_duration_ms.ts +14 -0
- package/lib/get_entry_row_key.ts +8 -0
- package/lib/get_initial_preferred_sheet_name.ts +34 -0
- package/lib/get_initial_reporting_range_inputs.ts +31 -0
- package/lib/get_input_class_name.ts +15 -0
- package/lib/get_merge_tags_confirm_dialog.ts +25 -0
- package/lib/get_period_range_ms.ts +43 -0
- package/lib/get_reporting_date_range_shortcut_inputs.ts +84 -0
- package/lib/get_reporting_period_totals.ts +39 -0
- package/lib/get_reporting_stats.ts +25 -0
- package/lib/get_restore_db_confirm_dialog.ts +14 -0
- package/lib/get_running_entry_key.ts +8 -0
- package/lib/get_serialized_entries_total_ms.ts +10 -0
- package/lib/get_sheet.ts +14 -0
- package/lib/get_sheet_report_stats.ts +22 -0
- package/lib/get_sheet_report_stats_for_range.ts +46 -0
- package/lib/get_sheet_tag_filter_snapshot.ts +22 -0
- package/lib/get_sheets_duration_in_range.ts +27 -0
- package/lib/get_tag_autocomplete_context.ts +32 -0
- package/lib/get_theme_snapshot.ts +16 -0
- package/lib/get_tracker_state.ts +67 -0
- package/lib/has_string_value.ts +6 -0
- package/lib/is_entry_in_day.ts +15 -0
- package/lib/is_idle_sheet_report.ts +8 -0
- package/lib/is_json_time_tracker_db.ts +14 -0
- package/lib/merge_tags_across_db.ts +79 -0
- package/lib/migrate_json_db.ts +56 -0
- package/lib/migrate_json_db_to_version_three.ts +51 -0
- package/lib/migrate_json_db_to_version_two.ts +50 -0
- package/lib/move_entries_to_sheet.ts +152 -0
- package/lib/move_entry_to_sheet.ts +82 -0
- package/lib/normalize_stored_tag.ts +16 -0
- package/lib/notify_settings_saved.ts +47 -0
- package/lib/parse_default_sheet_session_mode.ts +21 -0
- package/lib/parse_entry_from_input.ts +23 -0
- package/lib/parse_natural_language_date.ts +23 -0
- package/lib/parse_reporting_source_sheets.ts +22 -0
- package/lib/partition_sheet_report_stats.ts +30 -0
- package/lib/patch_tracker_action.ts +22 -0
- package/lib/persist_ui_preference.ts +18 -0
- package/lib/post_tracker_action.ts +22 -0
- package/lib/preferences/accent_color_preference.ts +21 -0
- package/lib/preferences/check_in_form_collapsed_preference.ts +20 -0
- package/lib/preferences/clear_tag_filters_on_sheet_change_preference.ts +20 -0
- package/lib/preferences/color_palette_preference.ts +21 -0
- package/lib/preferences/confirm_before_checkout_preference.ts +20 -0
- package/lib/preferences/confirm_destructive_actions_preference.ts +20 -0
- package/lib/preferences/default_reporting_range_preference.ts +21 -0
- package/lib/preferences/default_reporting_sort_preference.ts +24 -0
- package/lib/preferences/duration_format_preference.ts +19 -0
- package/lib/preferences/entry_list_sort_preference.ts +21 -0
- package/lib/preferences/tag_filter_mode_preference.ts +18 -0
- package/lib/preferences/theme_mode_preference.ts +18 -0
- package/lib/preferences/time_format_preference.ts +18 -0
- package/lib/preferences/timer_in_title_preference.ts +18 -0
- package/lib/preferences/timer_show_seconds_preference.ts +19 -0
- package/lib/preferences/week_starts_on_preference.ts +19 -0
- package/lib/prompt_check_out_at.ts +17 -0
- package/lib/prompt_entry_note.ts +14 -0
- package/lib/prune_sheet_tag_filter.ts +27 -0
- package/lib/read_db.ts +49 -0
- package/lib/read_db_backup_contents.ts +22 -0
- package/lib/read_document_compact_lists.ts +12 -0
- package/lib/read_document_theme.ts +14 -0
- package/lib/read_sheet_tag_filter.ts +26 -0
- package/lib/read_stored_active_sheet.ts +14 -0
- package/lib/read_stored_compact_lists.ts +24 -0
- package/lib/read_stored_default_sheet_fixed_name.ts +16 -0
- package/lib/read_stored_default_sheet_session_mode.ts +18 -0
- package/lib/read_stored_sheet_tag_filters.ts +28 -0
- package/lib/read_stored_theme.ts +18 -0
- package/lib/rename_sheet.ts +39 -0
- package/lib/rename_tag_across_db.ts +19 -0
- package/lib/resolve_active_sheet_name.ts +36 -0
- package/lib/resolve_session_preferred_sheet.ts +37 -0
- package/lib/resolve_theme.ts +18 -0
- package/lib/resolve_theme_mode_to_theme.ts +19 -0
- package/lib/restore_db_from_uploaded_json.ts +24 -0
- package/lib/serialize_entry.ts +27 -0
- package/lib/serialize_reporting_source_sheets.ts +19 -0
- package/lib/serialize_sheet_entries.ts +18 -0
- package/lib/set_accent_color.ts +12 -0
- package/lib/set_active_sheet.ts +18 -0
- package/lib/set_color_palette.ts +12 -0
- package/lib/set_compact_lists.ts +12 -0
- package/lib/set_default_sheet_fixed_name.ts +8 -0
- package/lib/set_default_sheet_session_mode.ts +11 -0
- package/lib/set_sheet_tag_filter.ts +13 -0
- package/lib/set_theme_mode.ts +19 -0
- package/lib/sheet_tag_filter_snapshots.ts +48 -0
- package/lib/sort_serialized_entries.ts +35 -0
- package/lib/sort_sheet_report_stats.ts +43 -0
- package/lib/subscribe_compact_lists.ts +25 -0
- package/lib/subscribe_sheet_tag_filters.ts +28 -0
- package/lib/subscribe_theme.ts +23 -0
- package/lib/sync_active_sheet_preference.ts +19 -0
- package/lib/tags_are_equal.ts +12 -0
- package/lib/theme_init_script.ts +11 -0
- package/lib/toggle_sheet_tag_filter.ts +28 -0
- package/lib/toggle_theme.ts +20 -0
- package/lib/types/confirm_dialog.ts +9 -0
- package/lib/types/data.ts +16 -0
- package/lib/types/generic_data.ts +25 -0
- package/lib/types/index.ts +2 -0
- package/lib/types/reporting.ts +59 -0
- package/lib/types/tag_management.ts +7 -0
- package/lib/types/theme.ts +3 -0
- package/lib/types/tracker_state.ts +39 -0
- package/lib/types/ui_preferences.ts +104 -0
- package/lib/types/ui_settings.ts +17 -0
- package/lib/ui_preference_store.ts +80 -0
- package/lib/ui_settings_init_script.ts +33 -0
- package/lib/use_check_in_form_collapsed.ts +18 -0
- package/lib/use_clear_tag_filters_on_sheet_change.ts +18 -0
- package/lib/use_confirm_before_checkout.ts +18 -0
- package/lib/use_confirm_destructive_actions.ts +18 -0
- package/lib/use_duration_format.ts +17 -0
- package/lib/use_entry_list_sort.ts +17 -0
- package/lib/use_tag_filter_mode.ts +17 -0
- package/lib/use_time_format.ts +17 -0
- package/lib/use_timer_in_title.ts +18 -0
- package/lib/use_timer_show_seconds.ts +18 -0
- package/lib/use_week_starts_on.ts +17 -0
- package/lib/validate_entry_times.ts +12 -0
- package/lib/week_starts_on_to_index.ts +8 -0
- package/lib/write_active_sheet_preference.ts +28 -0
- package/lib/write_db.ts +20 -0
- package/lib/write_sheet_tag_filter.ts +20 -0
- package/lib/write_stored_compact_lists.ts +15 -0
- package/lib/write_stored_default_sheet_fixed_name.ts +28 -0
- package/lib/write_stored_default_sheet_session_mode.ts +24 -0
- package/lib/write_stored_sheet_tag_filters.ts +18 -0
- package/lib/write_stored_theme.ts +12 -0
- package/next.config.ts +7 -0
- package/package.json +96 -0
- package/pnpm-workspace.yaml +7 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react'
|
|
4
|
+
|
|
5
|
+
import { SettingRadioGroup } from '@/components/setting-radio-group'
|
|
6
|
+
import { entry_list_sort_preference } from '@/lib/preferences/entry_list_sort_preference'
|
|
7
|
+
import { persist_ui_preference } from '@/lib/persist_ui_preference'
|
|
8
|
+
import { type EntryListSort } from '@/lib/types/ui_preferences'
|
|
9
|
+
|
|
10
|
+
const options: { value: EntryListSort; label: string }[] = [
|
|
11
|
+
{ value: 'newest', label: 'Newest first' },
|
|
12
|
+
{ value: 'oldest', label: 'Oldest first' },
|
|
13
|
+
{ value: 'duration', label: 'Longest duration' },
|
|
14
|
+
{ value: 'description', label: 'Description (A–Z)' },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
const set_entry_list_sort = (value: EntryListSort): void => {
|
|
18
|
+
persist_ui_preference(entry_list_sort_preference, value)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Setting: default sort order for the entry list on the home view.
|
|
23
|
+
*/
|
|
24
|
+
export function EntryListSortSetting() {
|
|
25
|
+
const value = useSyncExternalStore(
|
|
26
|
+
entry_list_sort_preference.subscribe,
|
|
27
|
+
entry_list_sort_preference.get_snapshot,
|
|
28
|
+
entry_list_sort_preference.get_server_snapshot,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<SettingRadioGroup<EntryListSort>
|
|
33
|
+
name="entry-list-sort"
|
|
34
|
+
legend="Entry list sort"
|
|
35
|
+
description="How entries are ordered on the active sheet."
|
|
36
|
+
value={value}
|
|
37
|
+
options={options}
|
|
38
|
+
on_change={set_entry_list_sort}
|
|
39
|
+
/>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { Checkbox } from '@/components/checkbox'
|
|
6
|
+
import { use_confirm_dialog } from '@/components/confirm-dialog-provider'
|
|
7
|
+
import { EntryActionsMenu } from '@/components/entry-actions-menu'
|
|
8
|
+
import { EntryNotesList } from '@/components/entry-notes-list'
|
|
9
|
+
import { EntryEditForm, type EntryEditFormValues } from '@/components/entry-edit-form'
|
|
10
|
+
import { EntryListBulkBar } from '@/components/entry-list-bulk-bar'
|
|
11
|
+
import { format_time } from '@/components/format_time'
|
|
12
|
+
import { get_delete_entries_confirm_dialog } from '@/lib/get_delete_entries_confirm_dialog'
|
|
13
|
+
import { get_delete_entry_confirm_dialog } from '@/lib/get_delete_entry_confirm_dialog'
|
|
14
|
+
import { format_display_tag } from '@/lib/format_display_tag'
|
|
15
|
+
import { format_duration } from '@/lib/format_duration'
|
|
16
|
+
import { get_entry_row_key } from '@/lib/get_entry_row_key'
|
|
17
|
+
import { use_confirm_destructive_actions } from '@/lib/use_confirm_destructive_actions'
|
|
18
|
+
import { use_duration_format } from '@/lib/use_duration_format'
|
|
19
|
+
import { use_time_format } from '@/lib/use_time_format'
|
|
20
|
+
import {
|
|
21
|
+
type SerializedEntry,
|
|
22
|
+
type SerializedSheet,
|
|
23
|
+
} from '@/lib/types/tracker_state'
|
|
24
|
+
|
|
25
|
+
interface EntryListProps {
|
|
26
|
+
title: string
|
|
27
|
+
entries: SerializedEntry[]
|
|
28
|
+
sheets: SerializedSheet[]
|
|
29
|
+
total_ms: number
|
|
30
|
+
empty_message: string
|
|
31
|
+
is_pending: boolean
|
|
32
|
+
show_sheet_name?: boolean
|
|
33
|
+
on_delete: (entry: SerializedEntry) => void
|
|
34
|
+
on_edit: (entry: SerializedEntry, values: EntryEditFormValues) => void
|
|
35
|
+
on_move: (entry: SerializedEntry, target_sheet_name: string) => void
|
|
36
|
+
on_move_many: (
|
|
37
|
+
entries: SerializedEntry[],
|
|
38
|
+
target_sheet_name: string,
|
|
39
|
+
) => void
|
|
40
|
+
on_delete_many: (entries: SerializedEntry[]) => void
|
|
41
|
+
on_edit_note: (
|
|
42
|
+
entry: SerializedEntry,
|
|
43
|
+
timestamp: string,
|
|
44
|
+
text: string,
|
|
45
|
+
) => void
|
|
46
|
+
on_add_note: (entry: SerializedEntry, text: string) => void
|
|
47
|
+
on_resume: (entry: SerializedEntry) => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const tag_item_class =
|
|
51
|
+
'rounded-full bg-tag-bg px-2 py-0.5 text-xs text-tag-text'
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Renders a list of time sheet entries with edit and delete actions.
|
|
55
|
+
*/
|
|
56
|
+
export function EntryList({
|
|
57
|
+
title,
|
|
58
|
+
entries,
|
|
59
|
+
sheets,
|
|
60
|
+
total_ms,
|
|
61
|
+
empty_message,
|
|
62
|
+
is_pending,
|
|
63
|
+
show_sheet_name = true,
|
|
64
|
+
on_delete,
|
|
65
|
+
on_edit,
|
|
66
|
+
on_move,
|
|
67
|
+
on_move_many,
|
|
68
|
+
on_delete_many,
|
|
69
|
+
on_edit_note,
|
|
70
|
+
on_add_note,
|
|
71
|
+
on_resume,
|
|
72
|
+
}: EntryListProps) {
|
|
73
|
+
const { confirm } = use_confirm_dialog()
|
|
74
|
+
const confirm_destructive_actions = use_confirm_destructive_actions()
|
|
75
|
+
const time_format = use_time_format()
|
|
76
|
+
const duration_format = use_duration_format()
|
|
77
|
+
const [editing_key, set_editing_key] = useState<string | null>(null)
|
|
78
|
+
const [selected_keys, set_selected_keys] = useState<Set<string>>(() => new Set())
|
|
79
|
+
|
|
80
|
+
const entry_keys = entries.map((entry) => get_entry_row_key(entry))
|
|
81
|
+
const selected_entries = entries.filter((entry) =>
|
|
82
|
+
selected_keys.has(get_entry_row_key(entry)),
|
|
83
|
+
)
|
|
84
|
+
const all_selected =
|
|
85
|
+
entries.length > 0 && selected_entries.length === entries.length
|
|
86
|
+
const some_selected =
|
|
87
|
+
selected_entries.length > 0 && selected_entries.length < entries.length
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
set_selected_keys((previous) => {
|
|
91
|
+
const valid_keys = new Set(entry_keys)
|
|
92
|
+
const next = new Set(
|
|
93
|
+
[...previous].filter((key) => valid_keys.has(key)),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return next.size === previous.size ? previous : next
|
|
97
|
+
})
|
|
98
|
+
}, [entry_keys.join('|')])
|
|
99
|
+
|
|
100
|
+
const toggle_entry = (row_key: string): void => {
|
|
101
|
+
set_selected_keys((previous) => {
|
|
102
|
+
const next = new Set(previous)
|
|
103
|
+
|
|
104
|
+
if (next.has(row_key)) {
|
|
105
|
+
next.delete(row_key)
|
|
106
|
+
} else {
|
|
107
|
+
next.add(row_key)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return next
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const toggle_all = (): void => {
|
|
115
|
+
if (all_selected) {
|
|
116
|
+
set_selected_keys(new Set())
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
set_selected_keys(new Set(entry_keys))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const clear_selection = (): void => {
|
|
124
|
+
set_selected_keys(new Set())
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const handle_bulk_move = (target_sheet_name: string): void => {
|
|
128
|
+
on_move_many(selected_entries, target_sheet_name)
|
|
129
|
+
clear_selection()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const handle_bulk_delete = async (): Promise<void> => {
|
|
133
|
+
if (selected_entries.length === 0) {
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const confirmed = confirm_destructive_actions
|
|
138
|
+
? await confirm(get_delete_entries_confirm_dialog(selected_entries))
|
|
139
|
+
: true
|
|
140
|
+
|
|
141
|
+
if (!confirmed) {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
on_delete_many(selected_entries)
|
|
146
|
+
clear_selection()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const has_selection = selected_entries.length > 0
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<section className="min-w-0">
|
|
153
|
+
<header className="mb-3 flex flex-col gap-2 border-b border-panel-border pb-2.5 compact:mb-2 compact:pb-1.5">
|
|
154
|
+
{has_selection ? (
|
|
155
|
+
<EntryListBulkBar
|
|
156
|
+
selected_count={selected_entries.length}
|
|
157
|
+
total_count={entries.length}
|
|
158
|
+
all_selected={all_selected}
|
|
159
|
+
some_selected={some_selected}
|
|
160
|
+
selected_entries={selected_entries}
|
|
161
|
+
sheets={sheets}
|
|
162
|
+
is_pending={is_pending}
|
|
163
|
+
on_toggle_all={toggle_all}
|
|
164
|
+
on_move={handle_bulk_move}
|
|
165
|
+
on_delete={() => void handle_bulk_delete()}
|
|
166
|
+
on_clear={clear_selection}
|
|
167
|
+
/>
|
|
168
|
+
) : (
|
|
169
|
+
<div className="flex items-center justify-between gap-3">
|
|
170
|
+
<div className="flex min-w-0 items-center gap-2.5">
|
|
171
|
+
{entries.length > 0 ? (
|
|
172
|
+
<Checkbox
|
|
173
|
+
className="shrink-0"
|
|
174
|
+
checked={all_selected}
|
|
175
|
+
indeterminate={some_selected}
|
|
176
|
+
disabled={is_pending}
|
|
177
|
+
aria_label="Select all entries"
|
|
178
|
+
on_change={toggle_all}
|
|
179
|
+
/>
|
|
180
|
+
) : null}
|
|
181
|
+
<h2 className="m-0 text-[0.72rem] font-semibold uppercase tracking-[0.06em]">
|
|
182
|
+
{title}
|
|
183
|
+
</h2>
|
|
184
|
+
<span className="text-[0.8rem] text-muted">
|
|
185
|
+
{entries.length === 0
|
|
186
|
+
? null
|
|
187
|
+
: entries.length === 1
|
|
188
|
+
? '1 entry'
|
|
189
|
+
: `${entries.length} entries`}
|
|
190
|
+
</span>
|
|
191
|
+
</div>
|
|
192
|
+
<p className="m-0 font-mono text-[0.85rem] text-muted">
|
|
193
|
+
{format_duration(total_ms, duration_format)} total
|
|
194
|
+
</p>
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</header>
|
|
198
|
+
{entries.length === 0 ? (
|
|
199
|
+
<p className="m-0 text-muted">{empty_message}</p>
|
|
200
|
+
) : (
|
|
201
|
+
<>
|
|
202
|
+
<ul className="m-0 flex list-none flex-col p-0">
|
|
203
|
+
{entries.map((entry) => {
|
|
204
|
+
const row_key = get_entry_row_key(entry)
|
|
205
|
+
const is_editing = editing_key === row_key
|
|
206
|
+
const is_selected = selected_keys.has(row_key)
|
|
207
|
+
|
|
208
|
+
if (is_editing) {
|
|
209
|
+
return (
|
|
210
|
+
<li
|
|
211
|
+
key={row_key}
|
|
212
|
+
className="block border-b border-panel-border py-2.5 last:border-b-0 compact:py-1.5"
|
|
213
|
+
>
|
|
214
|
+
<EntryEditForm
|
|
215
|
+
entry={entry}
|
|
216
|
+
is_pending={is_pending}
|
|
217
|
+
on_cancel={() => set_editing_key(null)}
|
|
218
|
+
on_save={(values) => {
|
|
219
|
+
on_edit(entry, values)
|
|
220
|
+
set_editing_key(null)
|
|
221
|
+
}}
|
|
222
|
+
/>
|
|
223
|
+
</li>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<li
|
|
229
|
+
key={row_key}
|
|
230
|
+
className={`group relative flex flex-col gap-0 border-b border-panel-border px-2 py-2.5 transition-colors duration-150 last:border-b-0 hover:bg-surface-hover compact:py-1.5 ${
|
|
231
|
+
is_selected
|
|
232
|
+
? 'bg-accent-soft hover:bg-accent-soft'
|
|
233
|
+
: ''
|
|
234
|
+
}`}
|
|
235
|
+
>
|
|
236
|
+
<div className="flex w-full min-w-0 items-center gap-2.5 compact:gap-1.5">
|
|
237
|
+
<label
|
|
238
|
+
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 has-disabled:cursor-not-allowed"
|
|
239
|
+
aria-label={`Select entry ${entry.description || 'Untitled entry'}`}
|
|
240
|
+
>
|
|
241
|
+
<Checkbox
|
|
242
|
+
nested
|
|
243
|
+
className={`shrink-0 pr-1 transition-opacity duration-150 compact:pr-0.5 ${
|
|
244
|
+
is_selected || has_selection
|
|
245
|
+
? 'opacity-100'
|
|
246
|
+
: 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
|
|
247
|
+
}`}
|
|
248
|
+
checked={is_selected}
|
|
249
|
+
disabled={is_pending}
|
|
250
|
+
on_change={() => toggle_entry(row_key)}
|
|
251
|
+
/>
|
|
252
|
+
<div className="min-w-0 flex-1">
|
|
253
|
+
<p className="m-0 overflow-wrap-anywhere font-semibold leading-snug compact:text-[0.9rem] compact:leading-tight">
|
|
254
|
+
{entry.description || 'Untitled entry'}
|
|
255
|
+
</p>
|
|
256
|
+
<div className="mt-0.5 flex flex-wrap items-center gap-1 text-[0.8rem] text-muted compact:mt-px compact:gap-0.5 compact:text-[0.72rem]">
|
|
257
|
+
{show_sheet_name ? (
|
|
258
|
+
<>
|
|
259
|
+
<span>{entry.sheetName}</span>
|
|
260
|
+
<span>·</span>
|
|
261
|
+
</>
|
|
262
|
+
) : null}
|
|
263
|
+
<span>#{entry.id}</span>
|
|
264
|
+
<span>·</span>
|
|
265
|
+
<span className="whitespace-nowrap">
|
|
266
|
+
{format_time(entry.start, time_format)}
|
|
267
|
+
{entry.end === null
|
|
268
|
+
? ' → now'
|
|
269
|
+
: ` → ${format_time(entry.end, time_format)}`}
|
|
270
|
+
</span>
|
|
271
|
+
{entry.tags.length > 0 ? (
|
|
272
|
+
<ul className="m-0 flex list-none flex-wrap gap-1 p-0">
|
|
273
|
+
{entry.tags.map((tag) => (
|
|
274
|
+
<li key={tag} className={tag_item_class}>
|
|
275
|
+
{format_display_tag(tag)}
|
|
276
|
+
</li>
|
|
277
|
+
))}
|
|
278
|
+
</ul>
|
|
279
|
+
) : null}
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
</label>
|
|
283
|
+
<div className="flex shrink-0 flex-row items-center gap-2 max-[860px]:flex-wrap max-[860px]:justify-end compact:gap-1.5">
|
|
284
|
+
<p
|
|
285
|
+
className={`m-0 whitespace-nowrap text-right font-mono text-[0.9rem] text-muted compact:text-[0.8rem] ${
|
|
286
|
+
entry.isActive ? 'text-accent' : ''
|
|
287
|
+
}`}
|
|
288
|
+
>
|
|
289
|
+
{format_duration(entry.durationMs, duration_format)}
|
|
290
|
+
</p>
|
|
291
|
+
<EntryActionsMenu
|
|
292
|
+
current_sheet_name={entry.sheetName}
|
|
293
|
+
sheets={sheets}
|
|
294
|
+
is_pending={is_pending}
|
|
295
|
+
on_edit={() => set_editing_key(row_key)}
|
|
296
|
+
on_add_note={(text) => on_add_note(entry, text)}
|
|
297
|
+
on_resume={() => on_resume(entry)}
|
|
298
|
+
entry_is_active={entry.isActive}
|
|
299
|
+
on_move={(target_sheet_name) =>
|
|
300
|
+
on_move(entry, target_sheet_name)
|
|
301
|
+
}
|
|
302
|
+
on_delete={async () => {
|
|
303
|
+
const confirmed = confirm_destructive_actions
|
|
304
|
+
? await confirm(
|
|
305
|
+
get_delete_entry_confirm_dialog(entry),
|
|
306
|
+
)
|
|
307
|
+
: true
|
|
308
|
+
|
|
309
|
+
if (confirmed) {
|
|
310
|
+
on_delete(entry)
|
|
311
|
+
}
|
|
312
|
+
}}
|
|
313
|
+
/>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
{entry.notes.length > 0 ? (
|
|
317
|
+
<div className="w-full pt-1 pl-[calc(0.85rem+0.5rem+0.35rem)] compact:pt-0.5 compact:pl-[calc(0.85rem+0.35rem+0.2rem)]">
|
|
318
|
+
<EntryNotesList
|
|
319
|
+
notes={entry.notes}
|
|
320
|
+
variant="inline"
|
|
321
|
+
is_pending={is_pending}
|
|
322
|
+
on_edit_note={(timestamp, text) =>
|
|
323
|
+
on_edit_note(entry, timestamp, text)
|
|
324
|
+
}
|
|
325
|
+
/>
|
|
326
|
+
</div>
|
|
327
|
+
) : null}
|
|
328
|
+
</li>
|
|
329
|
+
)
|
|
330
|
+
})}
|
|
331
|
+
</ul>
|
|
332
|
+
</>
|
|
333
|
+
)}
|
|
334
|
+
</section>
|
|
335
|
+
)
|
|
336
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type MouseEvent, type ReactNode, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { ChevronIcon } from '@/components/chevron-icon'
|
|
6
|
+
import { format_time } from '@/components/format_time'
|
|
7
|
+
import { NoteEditForm } from '@/components/note-edit-form'
|
|
8
|
+
import { PencilIcon } from '@/components/pencil-icon'
|
|
9
|
+
import { TrashIcon } from '@/components/trash-icon'
|
|
10
|
+
import { use_time_format } from '@/lib/use_time_format'
|
|
11
|
+
import { type SerializedNote } from '@/lib/types/tracker_state'
|
|
12
|
+
|
|
13
|
+
type EntryNotesListVariant = 'panel' | 'inline'
|
|
14
|
+
|
|
15
|
+
interface EntryNotesListProps {
|
|
16
|
+
notes: SerializedNote[]
|
|
17
|
+
variant?: EntryNotesListVariant
|
|
18
|
+
in_bar?: boolean
|
|
19
|
+
is_pending?: boolean
|
|
20
|
+
on_edit_note?: (timestamp: string, text: string) => void
|
|
21
|
+
on_delete_note?: (timestamp: string) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const edit_button_class =
|
|
25
|
+
'inline-flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-[0.35rem] border-0 bg-transparent p-0 text-muted hover:bg-surface-hover hover:text-foreground disabled:cursor-not-allowed disabled:opacity-55'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Renders notes attached to a time sheet entry.
|
|
29
|
+
*/
|
|
30
|
+
export function EntryNotesList({
|
|
31
|
+
notes,
|
|
32
|
+
variant = 'panel',
|
|
33
|
+
in_bar = false,
|
|
34
|
+
is_pending = false,
|
|
35
|
+
on_edit_note,
|
|
36
|
+
on_delete_note,
|
|
37
|
+
}: EntryNotesListProps) {
|
|
38
|
+
const time_format = use_time_format()
|
|
39
|
+
const [editing_timestamp, set_editing_timestamp] = useState<string | null>(
|
|
40
|
+
null,
|
|
41
|
+
)
|
|
42
|
+
const [is_expanded, set_is_expanded] = useState(false)
|
|
43
|
+
|
|
44
|
+
if (notes.length === 0) {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sorted_notes = [...notes].sort(
|
|
49
|
+
(left, right) =>
|
|
50
|
+
new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime(),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const is_inline = variant === 'inline'
|
|
54
|
+
const is_list_visible = is_expanded || editing_timestamp !== null
|
|
55
|
+
const toggle_label = is_inline
|
|
56
|
+
? `${notes.length} ${notes.length === 1 ? 'note' : 'notes'}`
|
|
57
|
+
: `Notes (${notes.length})`
|
|
58
|
+
|
|
59
|
+
const root_class = [
|
|
60
|
+
is_inline ? 'm-0 w-full p-0' : 'border-t border-accent-border pt-3.5',
|
|
61
|
+
in_bar && !is_inline
|
|
62
|
+
? 'border-[color-mix(in_srgb,var(--accent-border)_65%,var(--panel-border))]'
|
|
63
|
+
: '',
|
|
64
|
+
]
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.join(' ')
|
|
67
|
+
|
|
68
|
+
const toggle_class = is_inline
|
|
69
|
+
? 'inline-flex cursor-pointer items-center gap-1.5 border-0 bg-transparent p-0 font-inherit text-xs font-medium normal-case tracking-normal text-muted hover:text-foreground'
|
|
70
|
+
: 'inline-flex cursor-pointer items-center gap-1.5 border-0 bg-transparent px-0 py-0.5 font-inherit text-[0.72rem] font-semibold uppercase tracking-[0.04em] text-muted hover:text-foreground'
|
|
71
|
+
|
|
72
|
+
const list_class = is_inline
|
|
73
|
+
? `m-0 flex list-none flex-col gap-1.5 overflow-visible p-0 compact:gap-0.5 ${is_list_visible ? 'mt-1.5' : 'hidden'}`
|
|
74
|
+
: `m-0 grid max-h-48 list-none grid-cols-2 gap-2 overflow-y-auto p-0 max-[860px]:grid-cols-1 ${is_list_visible ? 'mt-1.5' : 'hidden'}`
|
|
75
|
+
|
|
76
|
+
const item_class = is_inline
|
|
77
|
+
? 'flex flex-col gap-0.5 rounded-sm border border-panel-border bg-ghost-bg px-2 py-1.5 compact:rounded-none compact:px-1.5 compact:py-1'
|
|
78
|
+
: 'flex flex-col gap-0.5 rounded-sm border border-panel-border bg-[color-mix(in_srgb,var(--panel)_55%,var(--background))] px-2.5 py-2'
|
|
79
|
+
|
|
80
|
+
const handle_save = (timestamp: string, text: string): void => {
|
|
81
|
+
on_edit_note?.(timestamp, text)
|
|
82
|
+
set_editing_timestamp(null)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const start_editing = (timestamp: string): void => {
|
|
86
|
+
set_is_expanded(true)
|
|
87
|
+
set_editing_timestamp(timestamp)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const handle_delete = (timestamp: string): void => {
|
|
91
|
+
if (editing_timestamp === timestamp) {
|
|
92
|
+
set_editing_timestamp(null)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
on_delete_note?.(timestamp)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const render_note_actions = (note: SerializedNote): ReactNode => {
|
|
99
|
+
if (on_edit_note === undefined && on_delete_note === undefined) {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className="flex shrink-0 gap-0.5">
|
|
105
|
+
{on_edit_note !== undefined ? (
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
className={edit_button_class}
|
|
109
|
+
aria-label="Edit note"
|
|
110
|
+
title="Edit note"
|
|
111
|
+
disabled={is_pending}
|
|
112
|
+
onClick={(event) => {
|
|
113
|
+
event.stopPropagation()
|
|
114
|
+
start_editing(note.timestamp)
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
<PencilIcon />
|
|
118
|
+
</button>
|
|
119
|
+
) : null}
|
|
120
|
+
{on_delete_note !== undefined ? (
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
className={`${edit_button_class} hover:text-danger`}
|
|
124
|
+
aria-label="Delete note"
|
|
125
|
+
title="Delete note"
|
|
126
|
+
disabled={is_pending}
|
|
127
|
+
onClick={(event) => {
|
|
128
|
+
event.stopPropagation()
|
|
129
|
+
handle_delete(note.timestamp)
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<TrashIcon />
|
|
133
|
+
</button>
|
|
134
|
+
) : null}
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const handle_toggle = (event: MouseEvent<HTMLButtonElement>): void => {
|
|
140
|
+
event.stopPropagation()
|
|
141
|
+
|
|
142
|
+
if (editing_timestamp !== null) {
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
set_is_expanded((previous) => !previous)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<section className={root_class} aria-label="Entry notes">
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
className={toggle_class}
|
|
154
|
+
aria-expanded={is_list_visible}
|
|
155
|
+
onClick={handle_toggle}
|
|
156
|
+
>
|
|
157
|
+
<ChevronIcon rotated={is_list_visible} />
|
|
158
|
+
<span>{toggle_label}</span>
|
|
159
|
+
</button>
|
|
160
|
+
<ul className={list_class}>
|
|
161
|
+
{sorted_notes.map((note, index) => {
|
|
162
|
+
const is_editing = editing_timestamp === note.timestamp
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<li key={`${note.timestamp}-${index}`} className={item_class}>
|
|
166
|
+
{is_editing ? (
|
|
167
|
+
<NoteEditForm
|
|
168
|
+
initial_text={note.text}
|
|
169
|
+
inline={is_inline}
|
|
170
|
+
is_pending={is_pending}
|
|
171
|
+
on_cancel={() => set_editing_timestamp(null)}
|
|
172
|
+
on_save={(text) => handle_save(note.timestamp, text)}
|
|
173
|
+
/>
|
|
174
|
+
) : is_inline ? (
|
|
175
|
+
<div className="flex w-full items-start justify-between gap-1.5">
|
|
176
|
+
<p className="m-0 flex min-w-0 flex-1 items-baseline gap-1.5 text-xs leading-snug text-foreground compact:text-[0.72rem]">
|
|
177
|
+
<time
|
|
178
|
+
className="shrink-0 font-mono text-[0.68rem] text-muted"
|
|
179
|
+
dateTime={note.timestamp}
|
|
180
|
+
>
|
|
181
|
+
{format_time(note.timestamp, time_format)}
|
|
182
|
+
</time>
|
|
183
|
+
<span className="min-w-0 overflow-wrap-anywhere whitespace-pre-wrap">
|
|
184
|
+
{note.text}
|
|
185
|
+
</span>
|
|
186
|
+
</p>
|
|
187
|
+
{render_note_actions(note)}
|
|
188
|
+
</div>
|
|
189
|
+
) : (
|
|
190
|
+
<>
|
|
191
|
+
<div className="flex w-full items-start justify-between gap-1.5">
|
|
192
|
+
<time
|
|
193
|
+
className="font-mono text-[0.72rem] text-muted"
|
|
194
|
+
dateTime={note.timestamp}
|
|
195
|
+
>
|
|
196
|
+
{format_time(note.timestamp, time_format)}
|
|
197
|
+
</time>
|
|
198
|
+
{render_note_actions(note)}
|
|
199
|
+
</div>
|
|
200
|
+
<p className="m-0 overflow-wrap-anywhere text-[0.9rem] leading-snug whitespace-pre-wrap">
|
|
201
|
+
{note.text}
|
|
202
|
+
</p>
|
|
203
|
+
</>
|
|
204
|
+
)}
|
|
205
|
+
</li>
|
|
206
|
+
)
|
|
207
|
+
})}
|
|
208
|
+
</ul>
|
|
209
|
+
</section>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useSyncExternalStore } from 'react'
|
|
4
|
+
|
|
5
|
+
import { format_display_tag } from '@/lib/format_display_tag'
|
|
6
|
+
import { get_button_class_name } from '@/lib/get_button_class_name'
|
|
7
|
+
import {
|
|
8
|
+
get_sheet_tag_filter_server_snapshot,
|
|
9
|
+
get_sheet_tag_filter_snapshot,
|
|
10
|
+
} from '@/lib/get_sheet_tag_filter_snapshot'
|
|
11
|
+
import { prune_sheet_tag_filter } from '@/lib/prune_sheet_tag_filter'
|
|
12
|
+
import { set_sheet_tag_filter } from '@/lib/set_sheet_tag_filter'
|
|
13
|
+
import { subscribe_sheet_tag_filters } from '@/lib/subscribe_sheet_tag_filters'
|
|
14
|
+
import { tags_are_equal } from '@/lib/tags_are_equal'
|
|
15
|
+
import { toggle_sheet_tag_filter } from '@/lib/toggle_sheet_tag_filter'
|
|
16
|
+
import { use_tag_filter_mode } from '@/lib/use_tag_filter_mode'
|
|
17
|
+
import { type TagStat } from '@/lib/types/tag_management'
|
|
18
|
+
|
|
19
|
+
interface EntryTagFilterProps {
|
|
20
|
+
sheet_name: string
|
|
21
|
+
sheet_tags: TagStat[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Toggle filters for showing only entries that match selected tags.
|
|
26
|
+
*/
|
|
27
|
+
export function EntryTagFilter({ sheet_name, sheet_tags }: EntryTagFilterProps) {
|
|
28
|
+
const tag_filter_mode = use_tag_filter_mode()
|
|
29
|
+
const filter_tags = useSyncExternalStore(
|
|
30
|
+
subscribe_sheet_tag_filters,
|
|
31
|
+
() => get_sheet_tag_filter_snapshot(sheet_name),
|
|
32
|
+
get_sheet_tag_filter_server_snapshot,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
prune_sheet_tag_filter(
|
|
37
|
+
sheet_name,
|
|
38
|
+
sheet_tags.map((tag) => tag.name),
|
|
39
|
+
)
|
|
40
|
+
}, [sheet_name, sheet_tags])
|
|
41
|
+
|
|
42
|
+
if (sheet_tags.length === 0) {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const has_filter = filter_tags.length > 0
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<fieldset className="m-0 rounded-lg border border-panel-border bg-panel p-3.5 shadow-sm compact:p-3">
|
|
50
|
+
<legend className="px-0.5 text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-muted">
|
|
51
|
+
Filter by tag
|
|
52
|
+
</legend>
|
|
53
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
|
54
|
+
<p className="m-0 shrink-0 text-[0.8rem] leading-snug text-muted">
|
|
55
|
+
{tag_filter_mode === 'any'
|
|
56
|
+
? 'Show entries that include any selected tag.'
|
|
57
|
+
: 'Show entries that include every selected tag.'}
|
|
58
|
+
</p>
|
|
59
|
+
<div
|
|
60
|
+
className="flex min-w-0 flex-1 flex-wrap justify-end gap-1.5"
|
|
61
|
+
role="group"
|
|
62
|
+
aria-label="Filter by tag"
|
|
63
|
+
>
|
|
64
|
+
{sheet_tags.map((tag) => {
|
|
65
|
+
const is_selected = filter_tags.some((filter_tag) =>
|
|
66
|
+
tags_are_equal(filter_tag, tag.name),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<button
|
|
71
|
+
key={tag.name}
|
|
72
|
+
type="button"
|
|
73
|
+
className={`${get_button_class_name('ghost', 'small')} ${
|
|
74
|
+
is_selected
|
|
75
|
+
? 'border-accent-border bg-accent-soft text-foreground'
|
|
76
|
+
: ''
|
|
77
|
+
}`}
|
|
78
|
+
aria-pressed={is_selected}
|
|
79
|
+
onClick={() => toggle_sheet_tag_filter(sheet_name, tag.name)}
|
|
80
|
+
>
|
|
81
|
+
{format_display_tag(tag.name)}
|
|
82
|
+
<span className="ml-1 font-normal text-muted">({tag.entryCount})</span>
|
|
83
|
+
</button>
|
|
84
|
+
)
|
|
85
|
+
})}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
{has_filter ? (
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
className={`${get_button_class_name('ghost', 'small')} mt-2.5`}
|
|
92
|
+
onClick={() => set_sheet_tag_filter(sheet_name, [])}
|
|
93
|
+
>
|
|
94
|
+
Clear filter
|
|
95
|
+
</button>
|
|
96
|
+
) : null}
|
|
97
|
+
</fieldset>
|
|
98
|
+
)
|
|
99
|
+
}
|