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,340 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, useMemo, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { ReportingDateRangePicker } from '@/components/reporting-date-range-picker'
|
|
6
|
+
import { ReportingSortControls } from '@/components/reporting-sort-controls'
|
|
7
|
+
import { TrackerTopbar } from '@/components/tracker-topbar'
|
|
8
|
+
import { build_reporting_stats } from '@/lib/build_reporting_stats'
|
|
9
|
+
import { default_reporting_sort_preference } from '@/lib/preferences/default_reporting_sort_preference'
|
|
10
|
+
import { format_duration } from '@/lib/format_duration'
|
|
11
|
+
import { get_date_range_ms_from_inputs } from '@/lib/get_date_range_ms_from_inputs'
|
|
12
|
+
import { get_initial_reporting_range_inputs } from '@/lib/get_initial_reporting_range_inputs'
|
|
13
|
+
import { parse_reporting_source_sheets } from '@/lib/parse_reporting_source_sheets'
|
|
14
|
+
import { sort_sheet_report_stats } from '@/lib/sort_sheet_report_stats'
|
|
15
|
+
import { use_duration_format } from '@/lib/use_duration_format'
|
|
16
|
+
import { use_week_starts_on } from '@/lib/use_week_starts_on'
|
|
17
|
+
import { week_starts_on_to_index } from '@/lib/week_starts_on_to_index'
|
|
18
|
+
import {
|
|
19
|
+
type ReportingDateRangeInputs,
|
|
20
|
+
type ReportingSourceSheet,
|
|
21
|
+
type SheetReportSort,
|
|
22
|
+
type SheetReportStats,
|
|
23
|
+
} from '@/lib/types/reporting'
|
|
24
|
+
|
|
25
|
+
interface ReportingViewProps {
|
|
26
|
+
source_sheets: ReportingSourceSheet[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const empty_range: ReportingDateRangeInputs = {
|
|
30
|
+
from_date: '',
|
|
31
|
+
to_date: '',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Renders per-sheet time-tracking statistics.
|
|
36
|
+
*/
|
|
37
|
+
export function ReportingView({ source_sheets }: ReportingViewProps) {
|
|
38
|
+
const duration_format = use_duration_format()
|
|
39
|
+
const week_starts_on = use_week_starts_on()
|
|
40
|
+
const [sort, set_sort] = useState<SheetReportSort>(() =>
|
|
41
|
+
default_reporting_sort_preference.read(),
|
|
42
|
+
)
|
|
43
|
+
const [range_inputs, set_range_inputs] = useState<ReportingDateRangeInputs>(
|
|
44
|
+
() => get_initial_reporting_range_inputs(undefined, week_starts_on),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
const sheets = useMemo(
|
|
48
|
+
() => parse_reporting_source_sheets(source_sheets),
|
|
49
|
+
[source_sheets],
|
|
50
|
+
)
|
|
51
|
+
const date_range = useMemo(
|
|
52
|
+
() =>
|
|
53
|
+
get_date_range_ms_from_inputs(
|
|
54
|
+
range_inputs.from_date,
|
|
55
|
+
range_inputs.to_date,
|
|
56
|
+
),
|
|
57
|
+
[range_inputs],
|
|
58
|
+
)
|
|
59
|
+
const range_is_partial =
|
|
60
|
+
(range_inputs.from_date.length > 0) !==
|
|
61
|
+
(range_inputs.to_date.length > 0)
|
|
62
|
+
const range_is_invalid =
|
|
63
|
+
range_is_partial ||
|
|
64
|
+
(range_inputs.from_date.length > 0 &&
|
|
65
|
+
range_inputs.to_date.length > 0 &&
|
|
66
|
+
date_range === null)
|
|
67
|
+
|
|
68
|
+
const week_starts_on_index = week_starts_on_to_index(week_starts_on)
|
|
69
|
+
const stats = useMemo(
|
|
70
|
+
() => build_reporting_stats(sheets, date_range, Date.now(), week_starts_on_index),
|
|
71
|
+
[sheets, date_range, week_starts_on_index],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const {
|
|
75
|
+
activeSheets,
|
|
76
|
+
grandAverageEntryMs,
|
|
77
|
+
grandTotalMs,
|
|
78
|
+
idleSheets,
|
|
79
|
+
periodTotals,
|
|
80
|
+
totalEntryCount,
|
|
81
|
+
} = stats
|
|
82
|
+
const sheet_count = activeSheets.length + idleSheets.length
|
|
83
|
+
const show_period_totals = date_range === null
|
|
84
|
+
|
|
85
|
+
const sorted_active_sheets = useMemo(
|
|
86
|
+
() => sort_sheet_report_stats(activeSheets, sort),
|
|
87
|
+
[activeSheets, sort],
|
|
88
|
+
)
|
|
89
|
+
const sorted_idle_sheets = useMemo(
|
|
90
|
+
() => sort_sheet_report_stats(idleSheets, sort),
|
|
91
|
+
[idleSheets, sort],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
<TrackerTopbar breadcrumb={{ current: 'Reporting' }} />
|
|
97
|
+
<main className="flex flex-col items-center gap-6 px-5 pb-10 pt-6">
|
|
98
|
+
<header className="flex w-full max-w-2xl flex-col gap-3">
|
|
99
|
+
<h1 className="m-0 text-center text-[1.35rem] font-[650] tracking-tight">
|
|
100
|
+
Reporting
|
|
101
|
+
</h1>
|
|
102
|
+
<p className="m-0 max-w-md self-center text-center text-[0.9rem] leading-relaxed text-muted">
|
|
103
|
+
{date_range === null
|
|
104
|
+
? 'Time tracked across all sheets.'
|
|
105
|
+
: 'Metrics filtered to the selected date range.'}
|
|
106
|
+
</p>
|
|
107
|
+
</header>
|
|
108
|
+
|
|
109
|
+
<ReportingDateRangePicker
|
|
110
|
+
range={range_inputs}
|
|
111
|
+
is_invalid={range_is_invalid}
|
|
112
|
+
on_range_change={set_range_inputs}
|
|
113
|
+
on_clear={() => set_range_inputs(empty_range)}
|
|
114
|
+
/>
|
|
115
|
+
|
|
116
|
+
<section
|
|
117
|
+
className="grid w-full max-w-2xl grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-4"
|
|
118
|
+
aria-label="Summary"
|
|
119
|
+
>
|
|
120
|
+
<SummaryCard
|
|
121
|
+
label={date_range === null ? 'Total tracked' : 'In range'}
|
|
122
|
+
value={format_duration(grandTotalMs, duration_format)}
|
|
123
|
+
/>
|
|
124
|
+
<SummaryCard
|
|
125
|
+
label="Avg per entry"
|
|
126
|
+
value={format_duration(grandAverageEntryMs, duration_format)}
|
|
127
|
+
/>
|
|
128
|
+
<SummaryCard label="Sheets" value={String(sheet_count)} />
|
|
129
|
+
<SummaryCard label="Entries" value={String(totalEntryCount)} />
|
|
130
|
+
</section>
|
|
131
|
+
|
|
132
|
+
{show_period_totals ? (
|
|
133
|
+
<section
|
|
134
|
+
className="grid w-full max-w-2xl grid-cols-1 gap-2 sm:grid-cols-3"
|
|
135
|
+
aria-label="Period totals"
|
|
136
|
+
>
|
|
137
|
+
<SummaryCard
|
|
138
|
+
label="Today"
|
|
139
|
+
value={format_duration(periodTotals.todayMs, duration_format)}
|
|
140
|
+
/>
|
|
141
|
+
<SummaryCard
|
|
142
|
+
label="This week"
|
|
143
|
+
value={format_duration(periodTotals.weekMs, duration_format)}
|
|
144
|
+
/>
|
|
145
|
+
<SummaryCard
|
|
146
|
+
label="This month"
|
|
147
|
+
value={format_duration(periodTotals.monthMs, duration_format)}
|
|
148
|
+
/>
|
|
149
|
+
</section>
|
|
150
|
+
) : null}
|
|
151
|
+
|
|
152
|
+
{sheet_count === 0 ? (
|
|
153
|
+
<p className="m-0 w-full max-w-2xl text-center text-[0.9rem] text-muted">
|
|
154
|
+
No sheets yet. Create a sheet on the tracker to start logging time.
|
|
155
|
+
</p>
|
|
156
|
+
) : range_is_invalid ? (
|
|
157
|
+
<p className="m-0 w-full max-w-2xl text-center text-[0.9rem] text-muted">
|
|
158
|
+
Choose both dates to filter metrics, or clear the range to see all
|
|
159
|
+
time.
|
|
160
|
+
</p>
|
|
161
|
+
) : (
|
|
162
|
+
<>
|
|
163
|
+
<ReportingSortControls sort={sort} on_sort_change={set_sort} />
|
|
164
|
+
{activeSheets.length === 0 ? (
|
|
165
|
+
<p className="m-0 w-full max-w-2xl text-center text-[0.9rem] text-muted">
|
|
166
|
+
{date_range === null
|
|
167
|
+
? 'No tracked time yet. Check in on a sheet to see stats here.'
|
|
168
|
+
: 'No tracked time in this date range.'}
|
|
169
|
+
</p>
|
|
170
|
+
) : (
|
|
171
|
+
<SheetStatsSection
|
|
172
|
+
title="Tracked sheets"
|
|
173
|
+
aria_label="Tracked sheet statistics"
|
|
174
|
+
>
|
|
175
|
+
{sorted_active_sheets.map((sheet) => (
|
|
176
|
+
<SheetStatsRow
|
|
177
|
+
key={sheet.sheetName}
|
|
178
|
+
sheet={sheet}
|
|
179
|
+
grand_total_ms={grandTotalMs}
|
|
180
|
+
duration_format={duration_format}
|
|
181
|
+
/>
|
|
182
|
+
))}
|
|
183
|
+
</SheetStatsSection>
|
|
184
|
+
)}
|
|
185
|
+
{idleSheets.length > 0 ? (
|
|
186
|
+
<SheetStatsSection
|
|
187
|
+
title={date_range === null ? 'Empty sheets' : 'Sheets in range'}
|
|
188
|
+
aria_label="Sheets without time in range"
|
|
189
|
+
muted
|
|
190
|
+
>
|
|
191
|
+
{sorted_idle_sheets.map((sheet) => (
|
|
192
|
+
<IdleSheetStatsRow
|
|
193
|
+
key={sheet.sheetName}
|
|
194
|
+
sheet={sheet}
|
|
195
|
+
in_range={date_range !== null}
|
|
196
|
+
/>
|
|
197
|
+
))}
|
|
198
|
+
</SheetStatsSection>
|
|
199
|
+
) : null}
|
|
200
|
+
</>
|
|
201
|
+
)}
|
|
202
|
+
</main>
|
|
203
|
+
</>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface SummaryCardProps {
|
|
208
|
+
label: string
|
|
209
|
+
value: string
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Compact summary metric for the reporting header.
|
|
214
|
+
*/
|
|
215
|
+
function SummaryCard({ label, value }: SummaryCardProps) {
|
|
216
|
+
return (
|
|
217
|
+
<div className="rounded-md border border-panel-border bg-panel p-3.5 shadow-sm">
|
|
218
|
+
<p className="m-0 text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-muted">
|
|
219
|
+
{label}
|
|
220
|
+
</p>
|
|
221
|
+
<p className="m-0 mt-1 text-[1.1rem] font-[650] tracking-tight">{value}</p>
|
|
222
|
+
</div>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface SheetStatsSectionProps {
|
|
227
|
+
title: string
|
|
228
|
+
aria_label: string
|
|
229
|
+
muted?: boolean
|
|
230
|
+
children: ReactNode
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Grouped list of sheet statistics with a section heading.
|
|
235
|
+
*/
|
|
236
|
+
function SheetStatsSection({
|
|
237
|
+
title,
|
|
238
|
+
aria_label,
|
|
239
|
+
muted = false,
|
|
240
|
+
children,
|
|
241
|
+
}: SheetStatsSectionProps) {
|
|
242
|
+
return (
|
|
243
|
+
<section className="flex w-full max-w-2xl flex-col gap-2">
|
|
244
|
+
<h2
|
|
245
|
+
className={`m-0 text-[0.72rem] font-semibold uppercase tracking-[0.06em] ${
|
|
246
|
+
muted ? 'text-muted' : 'text-foreground'
|
|
247
|
+
}`}
|
|
248
|
+
>
|
|
249
|
+
{title}
|
|
250
|
+
</h2>
|
|
251
|
+
<ul
|
|
252
|
+
className="m-0 flex list-none flex-col gap-2 p-0"
|
|
253
|
+
aria-label={aria_label}
|
|
254
|
+
>
|
|
255
|
+
{children}
|
|
256
|
+
</ul>
|
|
257
|
+
</section>
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
interface SheetStatsRowProps {
|
|
262
|
+
sheet: SheetReportStats
|
|
263
|
+
grand_total_ms: number
|
|
264
|
+
duration_format: import('@/lib/types/ui_preferences').DurationFormat
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Single sheet row with duration, share, and entry count.
|
|
269
|
+
*/
|
|
270
|
+
function SheetStatsRow({
|
|
271
|
+
sheet,
|
|
272
|
+
grand_total_ms,
|
|
273
|
+
duration_format,
|
|
274
|
+
}: SheetStatsRowProps) {
|
|
275
|
+
const share_percent =
|
|
276
|
+
grand_total_ms > 0 ? Math.round((sheet.totalMs / grand_total_ms) * 100) : 0
|
|
277
|
+
const bar_percent = grand_total_ms > 0 ? (sheet.totalMs / grand_total_ms) * 100 : 0
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<li className="rounded-md border border-panel-border bg-panel p-3.5 shadow-sm">
|
|
281
|
+
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
|
282
|
+
<h2 className="m-0 min-w-0 truncate text-[1rem] font-semibold">{sheet.sheetName}</h2>
|
|
283
|
+
<span className="shrink-0 font-mono text-[0.95rem] font-semibold text-accent">
|
|
284
|
+
{format_duration(sheet.totalMs, duration_format)}
|
|
285
|
+
</span>
|
|
286
|
+
</div>
|
|
287
|
+
<div
|
|
288
|
+
className="mt-2.5 h-1.5 overflow-hidden rounded-full bg-surface-raised"
|
|
289
|
+
role="presentation"
|
|
290
|
+
>
|
|
291
|
+
<div
|
|
292
|
+
className="h-full rounded-full bg-accent"
|
|
293
|
+
style={{ width: `${bar_percent}%` }}
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
296
|
+
<p className="m-0 mt-2 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.82rem] text-muted">
|
|
297
|
+
<span>{share_percent}% of total</span>
|
|
298
|
+
<span>
|
|
299
|
+
{sheet.entryCount} {sheet.entryCount === 1 ? 'entry' : 'entries'}
|
|
300
|
+
</span>
|
|
301
|
+
<span>
|
|
302
|
+
{sheet.entryCount === 0
|
|
303
|
+
? 'No average'
|
|
304
|
+
: `${format_duration(sheet.averageEntryMs, duration_format)} avg`}
|
|
305
|
+
</span>
|
|
306
|
+
{sheet.hasActiveEntry ? <span className="text-accent">Timer running</span> : null}
|
|
307
|
+
</p>
|
|
308
|
+
</li>
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
interface IdleSheetStatsRowProps {
|
|
313
|
+
sheet: SheetReportStats
|
|
314
|
+
in_range: boolean
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Compact row for sheets with no entries or no tracked time.
|
|
319
|
+
*/
|
|
320
|
+
function IdleSheetStatsRow({ sheet, in_range }: IdleSheetStatsRowProps) {
|
|
321
|
+
const status_label = in_range
|
|
322
|
+
? 'No time in range'
|
|
323
|
+
: sheet.entryCount === 0
|
|
324
|
+
? 'No entries'
|
|
325
|
+
: 'No tracked time'
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<li className="rounded-md border border-dashed border-panel-border bg-surface-raised/60 px-3.5 py-2.5">
|
|
329
|
+
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
|
330
|
+
<h3 className="m-0 min-w-0 truncate text-[0.95rem] font-medium text-muted">
|
|
331
|
+
{sheet.sheetName}
|
|
332
|
+
</h3>
|
|
333
|
+
<span className="shrink-0 text-[0.82rem] text-muted">{status_label}</span>
|
|
334
|
+
</div>
|
|
335
|
+
{sheet.hasActiveEntry ? (
|
|
336
|
+
<p className="m-0 mt-1.5 text-[0.82rem] text-accent">Timer running</p>
|
|
337
|
+
) : null}
|
|
338
|
+
</li>
|
|
339
|
+
)
|
|
340
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type ReactNode } from 'react'
|
|
4
|
+
|
|
5
|
+
interface SettingRadioGroupOption<T extends string> {
|
|
6
|
+
value: T
|
|
7
|
+
label: string
|
|
8
|
+
description?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SettingRadioGroupProps<T extends string> {
|
|
12
|
+
name: string
|
|
13
|
+
legend: ReactNode
|
|
14
|
+
description?: ReactNode
|
|
15
|
+
value: T
|
|
16
|
+
options: SettingRadioGroupOption<T>[]
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
on_change: (value: T) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Themed radio group for single-choice settings.
|
|
23
|
+
*/
|
|
24
|
+
export function SettingRadioGroup<T extends string>({
|
|
25
|
+
name,
|
|
26
|
+
legend,
|
|
27
|
+
description,
|
|
28
|
+
value,
|
|
29
|
+
options,
|
|
30
|
+
disabled = false,
|
|
31
|
+
on_change,
|
|
32
|
+
}: SettingRadioGroupProps<T>) {
|
|
33
|
+
return (
|
|
34
|
+
<fieldset className="m-0 border-0 p-0">
|
|
35
|
+
<legend className="m-0 mb-1 text-[0.95rem] font-semibold">
|
|
36
|
+
{legend}
|
|
37
|
+
</legend>
|
|
38
|
+
{description !== undefined ? (
|
|
39
|
+
<p className="m-0 mb-2 text-[0.8rem] leading-snug text-muted">
|
|
40
|
+
{description}
|
|
41
|
+
</p>
|
|
42
|
+
) : null}
|
|
43
|
+
<div className="flex flex-col gap-1.5">
|
|
44
|
+
{options.map((option) => {
|
|
45
|
+
const is_selected = option.value === value
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<label
|
|
49
|
+
key={option.value}
|
|
50
|
+
className={`flex cursor-pointer items-start gap-2 rounded-md border px-2.5 py-2 transition-colors duration-150 ${
|
|
51
|
+
is_selected
|
|
52
|
+
? 'border-accent-border bg-accent-soft'
|
|
53
|
+
: 'border-panel-border hover:bg-surface-hover'
|
|
54
|
+
}`}
|
|
55
|
+
>
|
|
56
|
+
<input
|
|
57
|
+
type="radio"
|
|
58
|
+
className="mt-1 shrink-0"
|
|
59
|
+
name={name}
|
|
60
|
+
value={option.value}
|
|
61
|
+
checked={is_selected}
|
|
62
|
+
disabled={disabled}
|
|
63
|
+
onChange={() => on_change(option.value)}
|
|
64
|
+
/>
|
|
65
|
+
<span className="flex flex-col gap-0.5">
|
|
66
|
+
<span className="text-[0.9rem] font-semibold">{option.label}</span>
|
|
67
|
+
{option.description !== undefined ? (
|
|
68
|
+
<span className="text-[0.8rem] leading-snug text-muted">
|
|
69
|
+
{option.description}
|
|
70
|
+
</span>
|
|
71
|
+
) : null}
|
|
72
|
+
</span>
|
|
73
|
+
</label>
|
|
74
|
+
)
|
|
75
|
+
})}
|
|
76
|
+
</div>
|
|
77
|
+
</fieldset>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import { usePathname } from 'next/navigation'
|
|
5
|
+
|
|
6
|
+
interface SettingsNavItem {
|
|
7
|
+
href: string
|
|
8
|
+
label: string
|
|
9
|
+
match: (pathname: string) => boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const items: SettingsNavItem[] = [
|
|
13
|
+
{
|
|
14
|
+
href: '/settings',
|
|
15
|
+
label: 'General',
|
|
16
|
+
match: (pathname) => pathname === '/settings',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
href: '/settings/display',
|
|
20
|
+
label: 'Display & layout',
|
|
21
|
+
match: (pathname) => pathname.startsWith('/settings/display'),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
href: '/settings/data',
|
|
25
|
+
label: 'Data & backup',
|
|
26
|
+
match: (pathname) => pathname.startsWith('/settings/data'),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
href: '/settings/tags',
|
|
30
|
+
label: 'Tag management',
|
|
31
|
+
match: (pathname) => pathname.startsWith('/settings/tags'),
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Sidebar navigation for the settings sub-pages.
|
|
37
|
+
*/
|
|
38
|
+
export function SettingsNav() {
|
|
39
|
+
const pathname = usePathname() ?? '/settings'
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<nav aria-label="Settings sections" className="w-full">
|
|
43
|
+
<ul className="m-0 flex w-full list-none flex-col gap-0.5 p-0">
|
|
44
|
+
{items.map((item) => {
|
|
45
|
+
const is_active = item.match(pathname)
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<li key={item.href}>
|
|
49
|
+
<Link
|
|
50
|
+
href={item.href}
|
|
51
|
+
aria-current={is_active ? 'page' : undefined}
|
|
52
|
+
className={`block rounded-md px-3 py-2 text-[0.9rem] no-underline transition-colors duration-150 ${
|
|
53
|
+
is_active
|
|
54
|
+
? 'bg-accent-soft text-foreground'
|
|
55
|
+
: 'text-muted hover:bg-surface-hover hover:text-foreground'
|
|
56
|
+
}`}
|
|
57
|
+
>
|
|
58
|
+
{item.label}
|
|
59
|
+
</Link>
|
|
60
|
+
</li>
|
|
61
|
+
)
|
|
62
|
+
})}
|
|
63
|
+
</ul>
|
|
64
|
+
</nav>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { SettingsNav } from '@/components/settings-nav'
|
|
4
|
+
import { SettingsSavedToast } from '@/components/settings-saved-toast'
|
|
5
|
+
import {
|
|
6
|
+
TrackerTopbar,
|
|
7
|
+
type TrackerTopbarBreadcrumb,
|
|
8
|
+
} from '@/components/tracker-topbar'
|
|
9
|
+
|
|
10
|
+
interface SettingsPageLayoutProps {
|
|
11
|
+
breadcrumb: TrackerTopbarBreadcrumb
|
|
12
|
+
title: string
|
|
13
|
+
description?: string
|
|
14
|
+
children: ReactNode
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Shared chrome for settings sub-pages: topbar, sidebar nav, and main content.
|
|
19
|
+
*/
|
|
20
|
+
export function SettingsPageLayout({
|
|
21
|
+
breadcrumb,
|
|
22
|
+
title,
|
|
23
|
+
description,
|
|
24
|
+
children,
|
|
25
|
+
}: SettingsPageLayoutProps) {
|
|
26
|
+
return (
|
|
27
|
+
<>
|
|
28
|
+
<SettingsSavedToast />
|
|
29
|
+
<TrackerTopbar breadcrumb={breadcrumb} />
|
|
30
|
+
<main className="mx-auto grid w-full max-w-[1120px] grid-cols-[minmax(12rem,16rem)_minmax(0,1fr)] gap-6 px-5 pb-10 pt-6 max-[860px]:grid-cols-1">
|
|
31
|
+
<aside className="flex flex-col gap-2">
|
|
32
|
+
<h2 className="m-0 text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-muted">
|
|
33
|
+
Settings
|
|
34
|
+
</h2>
|
|
35
|
+
<SettingsNav />
|
|
36
|
+
</aside>
|
|
37
|
+
<section className="flex min-w-0 flex-col gap-4">
|
|
38
|
+
<header className="flex flex-col gap-1">
|
|
39
|
+
<h1 className="m-0 text-[1.35rem] font-[650] tracking-tight">
|
|
40
|
+
{title}
|
|
41
|
+
</h1>
|
|
42
|
+
{description !== undefined ? (
|
|
43
|
+
<p className="m-0 text-[0.9rem] leading-relaxed text-muted">
|
|
44
|
+
{description}
|
|
45
|
+
</p>
|
|
46
|
+
) : null}
|
|
47
|
+
</header>
|
|
48
|
+
{children}
|
|
49
|
+
</section>
|
|
50
|
+
</main>
|
|
51
|
+
</>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
SETTINGS_SAVED_DEFAULT_MESSAGE,
|
|
7
|
+
subscribe_settings_saved,
|
|
8
|
+
} from '@/lib/notify_settings_saved'
|
|
9
|
+
|
|
10
|
+
const toast_visible_ms = 2800
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Fixed toast shown when a settings preference is persisted.
|
|
14
|
+
*/
|
|
15
|
+
export function SettingsSavedToast() {
|
|
16
|
+
const [message, set_message] = useState<string | null>(null)
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
let hide_timer: ReturnType<typeof setTimeout> | null = null
|
|
20
|
+
|
|
21
|
+
const unsubscribe = subscribe_settings_saved((next_message) => {
|
|
22
|
+
set_message(next_message)
|
|
23
|
+
|
|
24
|
+
if (hide_timer !== null) {
|
|
25
|
+
clearTimeout(hide_timer)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
hide_timer = setTimeout(() => {
|
|
29
|
+
set_message(null)
|
|
30
|
+
hide_timer = null
|
|
31
|
+
}, toast_visible_ms)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
return () => {
|
|
35
|
+
unsubscribe()
|
|
36
|
+
|
|
37
|
+
if (hide_timer !== null) {
|
|
38
|
+
clearTimeout(hide_timer)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
if (message === null) {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
role="status"
|
|
50
|
+
aria-live="polite"
|
|
51
|
+
aria-atomic="true"
|
|
52
|
+
className="pointer-events-none fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-md border border-accent-border bg-panel px-4 py-2.5 text-[0.88rem] font-medium text-foreground shadow-md transition-[opacity,transform] duration-200"
|
|
53
|
+
>
|
|
54
|
+
{message || SETTINGS_SAVED_DEFAULT_MESSAGE}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { HamburgerIcon } from '@/components/hamburger-icon'
|
|
6
|
+
|
|
7
|
+
interface SheetActionsMenuProps {
|
|
8
|
+
sheet_name: string
|
|
9
|
+
is_pending: boolean
|
|
10
|
+
can_delete: boolean
|
|
11
|
+
on_rename: () => void
|
|
12
|
+
on_delete: () => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const menu_item_class =
|
|
16
|
+
'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 disabled:cursor-not-allowed disabled:opacity-55'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Hamburger menu for sheet actions such as rename.
|
|
20
|
+
*/
|
|
21
|
+
export function SheetActionsMenu({
|
|
22
|
+
sheet_name,
|
|
23
|
+
is_pending,
|
|
24
|
+
can_delete,
|
|
25
|
+
on_rename,
|
|
26
|
+
on_delete,
|
|
27
|
+
}: SheetActionsMenuProps) {
|
|
28
|
+
const [is_open, set_is_open] = useState(false)
|
|
29
|
+
const menu_ref = useRef<HTMLDivElement>(null)
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!is_open) {
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const handle_pointer_down = (event: PointerEvent): void => {
|
|
37
|
+
if (
|
|
38
|
+
menu_ref.current !== null &&
|
|
39
|
+
!menu_ref.current.contains(event.target as Node)
|
|
40
|
+
) {
|
|
41
|
+
set_is_open(false)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
document.addEventListener('pointerdown', handle_pointer_down)
|
|
46
|
+
|
|
47
|
+
return () => {
|
|
48
|
+
document.removeEventListener('pointerdown', handle_pointer_down)
|
|
49
|
+
}
|
|
50
|
+
}, [is_open])
|
|
51
|
+
|
|
52
|
+
const close_menu = (): void => {
|
|
53
|
+
set_is_open(false)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="relative shrink-0 self-center" ref={menu_ref}>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
className="inline-flex cursor-pointer appearance-none items-center justify-center rounded-none border-0 bg-transparent p-0.5 text-muted shadow-none hover:opacity-75 focus-visible:outline-2 focus-visible:outline-input-focus-border focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-55"
|
|
61
|
+
aria-label={`Actions for sheet ${sheet_name}`}
|
|
62
|
+
aria-expanded={is_open}
|
|
63
|
+
aria-haspopup="menu"
|
|
64
|
+
disabled={is_pending}
|
|
65
|
+
onClick={() => set_is_open((open) => !open)}
|
|
66
|
+
>
|
|
67
|
+
<HamburgerIcon />
|
|
68
|
+
</button>
|
|
69
|
+
{is_open ? (
|
|
70
|
+
<ul
|
|
71
|
+
className="absolute right-0 top-full z-10 mt-1.5 min-w-56 list-none rounded-md border border-panel-border bg-panel p-1.5 shadow-md"
|
|
72
|
+
role="menu"
|
|
73
|
+
>
|
|
74
|
+
<li role="none">
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
className={menu_item_class}
|
|
78
|
+
role="menuitem"
|
|
79
|
+
disabled={is_pending}
|
|
80
|
+
onClick={() => {
|
|
81
|
+
close_menu()
|
|
82
|
+
on_rename()
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
Rename
|
|
86
|
+
</button>
|
|
87
|
+
</li>
|
|
88
|
+
<li className="my-1 border-t border-panel-border" role="separator" aria-hidden="true" />
|
|
89
|
+
<li role="none">
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
className={`${menu_item_class} text-danger`}
|
|
93
|
+
role="menuitem"
|
|
94
|
+
disabled={is_pending || !can_delete}
|
|
95
|
+
title={can_delete ? undefined : 'Cannot delete the last sheet'}
|
|
96
|
+
onClick={() => {
|
|
97
|
+
close_menu()
|
|
98
|
+
on_delete()
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
Delete sheet
|
|
102
|
+
</button>
|
|
103
|
+
</li>
|
|
104
|
+
</ul>
|
|
105
|
+
) : null}
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|