diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarBody.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarBody.razor new file mode 100644 index 0000000000..0353693bf1 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarBody.razor @@ -0,0 +1,77 @@ +@namespace Bit.BlazorUI +@implements IDisposable + +
+ @if (State.Mode == BitFullCalendarMode.Timeline) + { + switch (State.View) + { + case BitFullCalendarView.Day: + + break; + case BitFullCalendarView.Week: + + break; + case BitFullCalendarView.Month: + + break; + default: + + break; + } + } + else + { + switch (State.View) + { + case BitFullCalendarView.Month: + + break; + case BitFullCalendarView.Week: + + break; + case BitFullCalendarView.Day: + + break; + case BitFullCalendarView.Year: + + break; + case BitFullCalendarView.Agenda: + + break; + } + } +
+ +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + + [Parameter] public RenderFragment? MonthEventTemplate { get; set; } + [Parameter] public RenderFragment? WeekEventTemplate { get; set; } + [Parameter] public RenderFragment? DayEventTemplate { get; set; } + [Parameter] public RenderFragment? TimelineEventTemplate { get; set; } + + private List _singleDayEvents = []; + private List _multiDayEvents = []; + + protected override void OnInitialized() + { + State.OnStateChanged += Refresh; + ComputeEvents(); + } + + private void Refresh() + { + ComputeEvents(); + InvokeAsync(StateHasChanged); + } + + private void ComputeEvents() + { + _singleDayEvents = State.Events.Where(e => e.IsSingleDay).ToList(); + _multiDayEvents = State.Events.Where(e => e.IsMultiDay).ToList(); + } + + public void Dispose() => State.OnStateChanged -= Refresh; +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarToast.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarToast.razor new file mode 100644 index 0000000000..6fff5468c2 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarToast.razor @@ -0,0 +1,38 @@ +@namespace Bit.BlazorUI + +
+ @foreach (var toast in _toasts) + { +
+ @toast.Message +
+ } +
+ +@code { + private readonly List _toasts = []; + private int _nextId; + + public void Show(string message, bool isError = false) + { + var item = new ToastItem { Id = _nextId++, Message = message, IsError = isError }; + _toasts.Add(item); + StateHasChanged(); + _ = RemoveAfterDelay(item.Id); + } + + private async Task RemoveAfterDelay(int id) + { + await Task.Delay(3000); + _toasts.RemoveAll(t => t.Id == id); + await InvokeAsync(StateHasChanged); + } + + private class ToastItem + { + public int Id { get; set; } + public string Message { get; set; } = ""; + public bool IsError { get; set; } + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.razor new file mode 100644 index 0000000000..2a4e85c0e1 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.razor @@ -0,0 +1,225 @@ +@using System.Globalization +@namespace Bit.BlazorUI +@implements IDisposable + + + + + + + + + + +
+ + + +
+
+
+
+
+
+
+
+
+
+ +@code { + /// + /// Events displayed in the calendar. Assign a list from parent state; updates are synced on each + /// render when the reference or contents change. User-driven add, edit, and delete actions are + /// reported through — update this list (or your backing store) in the handler + /// to keep the UI in sync. + /// + [Parameter] public List? Events { get; set; } + + /// + /// Culture for the calendar. Accepts any CultureInfo, e.g. new CultureInfo("fa-IR"). + /// NOTE: do NOT use this parameter when the component is rendered with + /// @rendermode="InteractiveServer" - CultureInfo is not JSON-serializable. + /// Use instead for server-interactive scenarios. + /// + [Parameter] public CultureInfo? Culture { get; set; } + + /// + /// Culture name string (e.g. "fa-IR", "ar-SA", "fr-FR"). + /// Preferred over when using @rendermode="InteractiveServer" + /// because plain strings are safely serialized by Blazor's parameter persistence. + /// When both are supplied, CultureName takes precedence. + /// Blazor WebAssembly hosts must set BlazorWebAssemblyLoadAllGlobalizationData to + /// true (or load a custom ICU shard) for cultures outside the default EFIGS/CJK shards. + /// + [Parameter] public string? CultureName { get; set; } + + /// + /// Localized strings for calendar UI labels, buttons, dialogs, filters, and accessibility text. + /// Defaults to English; override individual properties on a + /// instance to localize the component without replacing built-in dialogs. + /// + [Parameter] public BitFullCalendarTexts Texts { get; set; } = new(); + + /// + /// Ordered list of event colors shown in pickers, filters, agenda headers, badges, and bullets. + /// Each entry has its own (matched against + /// ), + /// (the display name shown verbatim — for example "SkyBlue"), and + /// (any CSS color value used for swatches and badges). + /// When null or empty, is used. + /// + [Parameter] public IReadOnlyList? EventColorOptions { get; set; } + + /// + /// Resources displayed as rows in the resource timeline view. When null or empty, + /// the resource timeline tab is hidden from the header. Each event's + /// is matched against the resource Id. + /// + [Parameter] public IReadOnlyList? Resources { get; set; } + + /// + /// Raised when a user adds, edits, or deletes an event in the calendar UI. + /// + [Parameter] public EventCallback OnChange { get; set; } + + /// + /// When assigned, the built-in add dialog is suppressed. The callback receives a draft + /// with and + /// set from the interaction (for example the clicked day/week slot); + /// is empty and other fields are left at defaults. + /// Consumers should show their own UI and + /// raise (or mutate bound to parent state) after persisting changes. + /// + [Parameter] public EventCallback OnAddClick { get; set; } + + /// + /// When assigned, the built-in event details dialog is suppressed when an event is clicked. + /// The callback receives the clicked . Consumers should + /// show their own event details UI. This applies to all views (day, week, month, agenda) and + /// to multi-day event rows and event list dialogs. + /// + [Parameter] public EventCallback OnEventClick { get; set; } + + /// + /// Raised when the visible date range changes — for example when the user navigates + /// with prev/next/today buttons or switches views. The callback receives the inclusive + /// start and end dates of the new range together with the active view. + /// + [Parameter] public EventCallback OnDateChange { get; set; } + + /// + /// Optional template for customizing event rendering in the day view. + /// When provided, replaces the default event card content inside the time-grid blocks. + /// + [Parameter] public RenderFragment? DayEventTemplate { get; set; } + + /// + /// Optional template for customizing event rendering in the week view. + /// When provided, replaces the default event card content inside the time-grid blocks. + /// + [Parameter] public RenderFragment? WeekEventTemplate { get; set; } + + /// + /// Optional template for customizing event rendering in the month view. + /// When provided, replaces the default event badge content inside month grid cells. + /// + [Parameter] public RenderFragment? MonthEventTemplate { get; set; } + + /// + /// Optional template for customizing event rendering in the resource timeline view. + /// When provided, replaces the default event card content inside the timeline blocks. + /// + [Parameter] public RenderFragment? TimelineEventTemplate { get; set; } + + /// + /// When true, the built-in color and attendee filter dropdowns are hidden from the calendar header. + /// Consumers can provide their own external filter UI and pass pre-filtered events to the calendar. + /// + [Parameter] public bool HideFilters { get; set; } + + /// + /// When true, the built-in settings gear button is hidden from the calendar header. + /// Consumers can still drive settings programmatically through the object. + /// + [Parameter] public bool HideSettings { get; set; } + + /// + /// Configuration options controlling initial calendar preferences + /// such as dark mode, time format, badge variant, day start hour, and agenda grouping. + /// Values are applied when the component initializes or when a new instance is assigned. + /// + [Parameter] public BitFullCalendarOptions Options { get; set; } = new(); + + /// + /// Initial layout mode. shows the standard + /// day/week/month/year/agenda views. switches to + /// the resource × time layout (day, week, month) and requires to contain + /// at least one entry; otherwise the Timeline tab and mode have no effect. + /// + [Parameter] public BitFullCalendarMode? InitialMode { get; set; } + + public BitFullCalendarState State { get; set; } = new(); + private BitFullCalendarChangeNotifier _changeNotifier = default!; + private BitFullCalendarColorScheme _colorScheme = new(null); + private BitFullCalendarOptions? _appliedOptions; + + private string RootCssClass => "bfc-root"; + + private CultureInfo ResolveCulture() => + CultureName is { Length: > 0 } name + ? new CultureInfo(name) + : Culture ?? CultureInfo.CurrentUICulture; + + protected override void OnInitialized() + { + State.Initialize(Events ?? [], ResolveCulture()); + ApplyOptions(); + if (InitialMode.HasValue) + State.SetMode(InitialMode.Value); + _changeNotifier = new BitFullCalendarChangeNotifier(State, args => OnChange.InvokeAsync(args)); + State.OnStateChanged += HandleStateChanged; + State.OnDateRangeChanged += HandleDateRangeChanged; + } + + protected override void OnParametersSet() + { + _colorScheme = new BitFullCalendarColorScheme(EventColorOptions); + var resolved = ResolveCulture(); + if (!string.Equals(resolved.Name, State.Culture.Name, StringComparison.Ordinal)) + State.SetCulture(resolved); + + if (Events is not null) + State.SyncEvents(Events); + + State.SyncResources(Resources); + + ApplyOptions(); + } + + private void ApplyOptions() + { + if (ReferenceEquals(Options, _appliedOptions)) + return; + + _appliedOptions = Options; + State.SetUse24HourFormat(Options.Use24HourFormat); + State.SetBadgeVariant(Options.BadgeVariant); + State.SetStartOfDayHour(Options.StartOfDayHour); + State.SetAgendaModeGroupBy(Options.AgendaModeGroupBy); + } + + private void HandleStateChanged() => InvokeAsync(StateHasChanged); + + private void HandleDateRangeChanged(BitFullCalendarDateChangeEventArgs args) + => InvokeAsync(() => OnDateChange.InvokeAsync(args)); + + public void Dispose() + { + State.OnStateChanged -= HandleStateChanged; + State.OnDateRangeChanged -= HandleDateRangeChanged; + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.scss new file mode 100644 index 0000000000..bdba709d68 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.scss @@ -0,0 +1,2046 @@ +/* BitFullCalendar */ + +@import "../../../Bit.BlazorUI/Styles/functions.scss"; + +/* ===== CSS Variables ===== + The calendar maps its internal --bfc-* custom properties onto the Bit BlazorUI theme tokens + (--bit-clr-*, shape and shadow tokens). Because those tokens are redefined by the active Bit + theme (including dark mode via :root[bit-theme="dark"]), the calendar follows the application + theme automatically without any component-level theme or dark-mode switch. */ +.bfc-root { + --bfc-bg: #{$clr-bg-pri}; + --bfc-bg-secondary: #{$clr-bg-sec}; + --bfc-bg-hover: #{$clr-bg-pri-hover}; + --bfc-border: #{$clr-brd-ter}; + --bfc-text: #{$clr-fg-pri}; + --bfc-text-secondary: #{$clr-fg-sec}; + --bfc-text-muted: #{$clr-fg-ter}; + --bfc-primary: #{$clr-pri}; + --bfc-primary-hover: #{$clr-pri-hover}; + --bfc-primary-text: #{$clr-pri-text}; + --bfc-danger: #{$clr-err}; + --bfc-danger-hover: #{$clr-err-hover}; + --bfc-radius: #{$shp-border-radius}; + --bfc-radius-sm: #{$shp-border-radius}; + --bfc-shadow: #{$box-shadow-md}; + --bfc-shadow-lg: #{$box-shadow-lg}; + --bfc-hour-height: 96px; + --bfc-timeline-color: #{$clr-err}; + --bfc-resize-ring: color-mix(in srgb, #{$clr-pri} 65%, transparent); + --bfc-resize-glow: color-mix(in srgb, #{$clr-pri} 22%, transparent); + --bfc-resize-preview-bg: #{$clr-bg-pri}; + --bfc-resize-preview-border: color-mix(in srgb, #{$clr-pri} 45%, transparent); + --bfc-resize-handle-line: #{$clr-pri}; +} + +/* ===== Base ===== */ +.bfc-root { + font-family: #{$tg-font-family}; + background: var(--bfc-bg); + color: var(--bfc-text); + border: 1px solid var(--bfc-border); + border-radius: var(--bfc-radius); + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; +} + +/* ===== Header ===== */ +.bfc-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--bfc-border); + background: var(--bfc-bg); + flex-wrap: wrap; + gap: 8px; +} + +.bfc-header-left, .bfc-header-center, .bfc-header-right { + display: flex; + align-items: center; + gap: 8px; +} + +.bfc-header-left { flex: 1; } +.bfc-header-right { flex: 1; justify-content: flex-end; } + +/* ===== Buttons ===== */ +.bfc-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid var(--bfc-border); + border-radius: var(--bfc-radius-sm); + background: var(--bfc-bg); + color: var(--bfc-text); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + line-height: 1.4; +} +.bfc-btn:hover { background: var(--bfc-bg-hover); } + +.bfc-btn-primary { + background: var(--bfc-primary); + color: var(--bfc-primary-text); + border-color: var(--bfc-primary); +} +.bfc-btn-primary:hover { background: var(--bfc-primary-hover); } + +.bfc-btn-danger { + background: var(--bfc-danger); + color: #fff; + border-color: var(--bfc-danger); +} +.bfc-btn-danger:hover { background: var(--bfc-danger-hover); } + +.bfc-btn-icon { + padding: 6px; + width: 32px; + height: 32px; +} + +.bfc-btn-sm { padding: 4px 8px; font-size: 12px; } + +.bfc-btn-group { + display: inline-flex; + border: 1px solid var(--bfc-border); + border-radius: var(--bfc-radius-sm); + overflow: hidden; +} +.bfc-btn-group .bfc-btn { + border: none; + border-radius: 0; + border-right: 1px solid var(--bfc-border); +} +.bfc-btn-group .bfc-btn:last-child { border-right: none; } +.bfc-btn-group .bfc-btn.active { + background: var(--bfc-primary); + color: var(--bfc-primary-text); +} + +/* ===== View Tabs ===== */ +.bfc-view-tabs { + display: inline-flex; + background: var(--bfc-bg-secondary); + border-radius: var(--bfc-radius-sm); + padding: 2px; + gap: 2px; +} +.bfc-view-tab { + padding: 5px 12px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--bfc-text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} +.bfc-view-tab:hover { color: var(--bfc-text); background: var(--bfc-bg-hover); } +.bfc-view-tab.active { + background: var(--bfc-bg); + color: var(--bfc-text); + box-shadow: var(--bfc-shadow); +} + +/* ===== Calendar Body ===== */ +.bfc-body { + flex: 1; + min-height: 0; + overflow: hidden; + position: relative; + animation: bfcFadeIn 0.2s ease; +} + +@keyframes bfcFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* ===== Month View ===== */ +.bfc-month { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow-y: auto; +} + +.bfc-month-header { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + border-bottom: 1px solid var(--bfc-border); + flex-shrink: 0; +} +.bfc-month-header-cell { + padding: 8px; + text-align: center; + font-size: 12px; + font-weight: 600; + color: var(--bfc-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.bfc-month-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + flex: 0 0 auto; + align-content: start; +} + +.bfc-month-cell { + border-right: 1px solid var(--bfc-border); + border-bottom: 1px solid var(--bfc-border); + padding: 4px; + min-height: 100px; + min-width: 0; + position: relative; + cursor: pointer; + transition: background 0.1s; + overflow: clip; + overflow-clip-margin: 4px; +} +.bfc-month-cell:hover { background: var(--bfc-bg-hover); } +.bfc-month-cell:nth-child(7n) { border-right: none; } +.bfc-month-cell.other-month { opacity: 0.4; } + +.bfc-month-cell-day { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + font-size: 13px; + font-weight: 500; + margin-bottom: 2px; +} +.bfc-month-cell-day.today { + background: var(--bfc-primary); + color: var(--bfc-primary-text); +} + +.bfc-month-events { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + overflow: hidden; +} + +.bfc-month-event-slot { + height: 22px; + min-width: 0; + overflow: hidden; +} + +.bfc-month-more { + font-size: 11px; + color: var(--bfc-text-secondary); + padding: 2px 4px; + cursor: pointer; + font-weight: 500; +} +.bfc-month-more:hover { color: var(--bfc-primary); } + +/* ===== Event Badge (month view) ===== */ +.bfc-event-badge { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + height: 22px; + max-width: 100%; + min-width: 0; + padding: 0 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + cursor: grab; + overflow: hidden; + border: 1px solid transparent; + transition: filter 0.1s; + user-select: none; +} + +.bfc-event-badge-content { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + flex: 1; + overflow: hidden; +} +.bfc-event-badge:hover { filter: brightness(0.95); } + +.bfc-event-badge.position-first { border-radius: 4px 0 0 4px; margin-right: 0; border-right: none; } +.bfc-event-badge.position-middle { border-radius: 0; margin: 0; border-left: none; border-right: none; } +.bfc-event-badge.position-last { border-radius: 0 4px 4px 0; margin-left: 0; border-left: none; } + +.bfc-event-badge-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} +.bfc-event-badge-time { + font-size: 10px; + opacity: 0.8; + flex-shrink: 0; + white-space: nowrap; +} + +/* ===== Event Bullet ===== */ +.bfc-bullet { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; + background: var(--bfc-evt-color, var(--bfc-primary)); +} + +/* ===== Color Classes ===== + Colors are driven by the per-event CSS custom property --bfc-evt-color, set inline by the + component from BitFullCalendarColorOption.Value. The classes derive a soft chip surface + (bfc-color) and a solid swatch (bfc-bg) from that single value using color-mix(), so any CSS + color works without authoring extra rules. */ +.bfc-color { + background: color-mix(in srgb, var(--bfc-evt-color, var(--bfc-primary)) 18%, var(--bfc-bg)); + color: color-mix(in srgb, var(--bfc-evt-color, var(--bfc-primary)) 75%, var(--bfc-text)); + border-color: color-mix(in srgb, var(--bfc-evt-color, var(--bfc-primary)) 32%, var(--bfc-bg)); +} + +.bfc-bg { + background: var(--bfc-evt-color, var(--bfc-primary)); +} + +/* Dot-variant badge (neutral background, colored dot) */ +.bfc-event-badge.dot-variant { + background: var(--bfc-bg-secondary); + color: var(--bfc-text); + border-color: var(--bfc-border); +} + +/* ===== Day/Week Shared: Time Grid ===== */ +.bfc-timegrid-wrapper { + display: flex; + flex: 1; + overflow-y: auto; + position: relative; + align-items: flex-start; +} + +.bfc-time-column { + flex-shrink: 0; + width: 60px; + border-inline-end: 1px solid var(--bfc-border); +} + +.bfc-time-slot-label { + height: var(--bfc-hour-height); + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 0; + font-size: 11px; + color: var(--bfc-text-muted); + font-weight: 500; + position: relative; + top: -7px; +} + +.bfc-timegrid { + flex: 1; + position: relative; + min-height: calc(24 * var(--bfc-hour-height)); +} + +.bfc-timegrid-day { + position: relative; + flex: 1; +} + +.bfc-timegrid-days { + display: flex; + flex: 1; + position: relative; + min-height: calc(24 * var(--bfc-hour-height)); +} + +.bfc-hour-row { + height: var(--bfc-hour-height); + border-bottom: 1px solid var(--bfc-border); + position: relative; +} +/* Bottom half of each hour — must stretch from 50% to bottom or it has ~0 height + and all drops land on the parent row (always :00). */ +.bfc-hour-row-half { + position: absolute; + top: 50%; + right: 0; + bottom: 0; + left: 0; + z-index: 1; + border-top: 1px dashed var(--bfc-border); + opacity: 0.5; +} + +/* Event block in day/week */ +.bfc-event-block { + position: relative; + z-index: 2; + padding: 4px 8px; + border-radius: var(--bfc-radius-sm); + font-size: 12px; + cursor: grab; + width: 100%; + height: 100%; + box-sizing: border-box; + min-width: 0; + pointer-events: auto; + overflow: hidden; + border: 1px solid transparent; + user-select: none; + transition: box-shadow 0.15s, filter 0.1s; +} +.bfc-event-block:hover { filter: brightness(0.95); z-index: 5; box-shadow: var(--bfc-shadow); } +.bfc-event-block-pass-through { pointer-events: none; } + +/* Wrapper around an event block when overlapping events are stacked diagonally. + A subtle box-shadow on the right edge separates the cards behind it. The hovered + card and its wrapper are lifted so the full content becomes readable. */ +.bfc-event-stack-item:has(.bfc-event-block:hover), +.bfc-event-stack-item:focus-within { + z-index: 20 !important; +} +.bfc-event-stack-item .bfc-event-block { + box-shadow: -1px 0 0 0 var(--bfc-bg, #fff); +} +.bfc-event-stack-item .bfc-event-block:hover { + z-index: 21; +} +.bfc-event-block-resizing { + z-index: 12; + cursor: ns-resize; + filter: brightness(1.02) saturate(1.05); + will-change: transform, height; + border-color: var(--bfc-resize-ring) !important; + box-shadow: + 0 0 0 2px var(--bfc-resize-ring), + 0 0 0 5px var(--bfc-resize-glow), + 0 12px 28px rgba(15, 23, 42, 0.18), + var(--bfc-shadow); + transition: box-shadow 0.2s ease, filter 0.2s ease, border-color 0.2s ease; +} + +.bfc-event-block-title { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.bfc-event-block-time { + font-size: 11px; + opacity: 0.8; + white-space: nowrap; +} + +/* Resize handles */ +.bfc-resize-handle { + position: absolute; + left: 0; + right: 0; + height: 10px; + cursor: ns-resize; + z-index: 3; + touch-action: none; + border-radius: 4px; + transition: background 0.15s ease, box-shadow 0.15s ease; +} +.bfc-resize-handle::after { + content: ""; + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 28px; + height: 3px; + border-radius: 999px; + background: var(--bfc-resize-handle-line); + opacity: 0; + transition: opacity 0.15s ease, width 0.15s ease, box-shadow 0.15s ease; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); +} +.bfc-resize-handle-top { top: -5px; } +.bfc-resize-handle-top::after { top: 3px; } +.bfc-resize-handle-bottom { bottom: -5px; } +.bfc-resize-handle-bottom::after { bottom: 3px; } +.bfc-resize-handle:hover { + background: var(--bfc-resize-glow); +} +.bfc-resize-handle:hover::after { + opacity: 0.85; + width: 36px; +} +.bfc-event-block-resizing .bfc-resize-handle-top { + background: linear-gradient(to bottom, var(--bfc-resize-glow), transparent); +} +.bfc-event-block-resizing .bfc-resize-handle-bottom { + background: linear-gradient(to top, var(--bfc-resize-glow), transparent); +} +.bfc-event-block-resizing .bfc-resize-handle::after { + opacity: 1; + width: 40px; + box-shadow: 0 0 0 2px var(--bfc-resize-glow), 0 2px 6px color-mix(in srgb, var(--bfc-primary) 35%, transparent); +} +.bfc-event-block-resizing.bfc-resize-dir-top .bfc-resize-handle-top::after, +.bfc-event-block-resizing.bfc-resize-dir-bottom .bfc-resize-handle-bottom::after { + height: 4px; + width: 44px; + box-shadow: 0 0 0 3px var(--bfc-resize-glow), 0 2px 10px color-mix(in srgb, var(--bfc-primary) 45%, transparent); +} + +.bfc-resize-preview { + position: absolute; + right: 6px; + bottom: 6px; + left: auto; + top: auto; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px 6px 8px; + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; + color: var(--bfc-primary); + background: var(--bfc-resize-preview-bg); + border: 1px solid var(--bfc-resize-preview-border); + border-radius: var(--bfc-radius-sm); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.5) inset, + 0 4px 14px color-mix(in srgb, var(--bfc-primary) 20%, transparent), + var(--bfc-shadow); + z-index: 14; + white-space: nowrap; + pointer-events: none; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} +.bfc-event-block-resizing.bfc-resize-dir-top .bfc-resize-preview { + top: 6px; + bottom: auto; +} +.bfc-resize-preview-icon { + flex-shrink: 0; + opacity: 0.9; +} +.bfc-resize-preview-time { + color: var(--bfc-text); + font-weight: 700; +} + +/* When resizing a timeline event from the start edge, anchor the floating preview to the + start side so it stays visible for short events. */ +.bfc-timeline-event-resizing.bfc-resize-dir-start .bfc-resize-preview { + left: 6px; + right: auto; +} + +/* ===== Timeline (current time indicator) ===== */ +.bfc-timeline { + position: absolute; + left: 0; + right: 0; + z-index: 4; + pointer-events: none; + display: flex; + align-items: center; +} +.bfc-timeline-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--bfc-timeline-color); + margin-left: -5px; + flex-shrink: 0; +} +.bfc-timeline-line { + flex: 1; + height: 2px; + background: var(--bfc-timeline-color); +} +.bfc-timeline-label { + position: absolute; + left: -54px; + font-size: 10px; + font-weight: 600; + color: var(--bfc-timeline-color); + white-space: nowrap; +} + +/* ===== Day View Layout ===== */ +.bfc-day-layout { + display: flex; + height: 100%; +} +.bfc-day-main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} +.bfc-day-sidebar { + width: 280px; + border-left: 1px solid var(--bfc-border); + padding: 16px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; + background: var(--bfc-bg-secondary); +} + +.bfc-day-header { + display: flex; + align-items: center; + gap: 0; + padding: 0; + border-bottom: 1px solid var(--bfc-border); + font-weight: 600; +} +/* Aligns with .bfc-time-column (60px) so the time-axis border continues through the header in LTR and RTL */ +.bfc-day-header-time-gutter { + width: 60px; + flex-shrink: 0; + box-sizing: border-box; + border-inline-end: 1px solid var(--bfc-border); +} +.bfc-day-header-titles { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + padding: 12px 0; + min-width: 0; +} +.bfc-day-header-date { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + font-size: 18px; + font-weight: 700; +} +.bfc-day-header-date.today { + background: var(--bfc-primary); + color: var(--bfc-primary-text); +} + +/* Multi-day row above time grid */ +.bfc-multiday-row { + padding: 4px 0; + padding-inline-start: 60px; + border-bottom: 1px solid var(--bfc-border); + display: flex; + flex-direction: column; + gap: 2px; + min-height: 28px; + min-width: 0; + overflow: hidden; +} + +/* ===== Week View ===== */ +.bfc-week-layout { + display: flex; + flex-direction: column; + height: 100%; +} + +.bfc-week-scroll { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow-y: auto; + position: relative; +} + +.bfc-week-header { + display: flex; + border-bottom: 1px solid var(--bfc-border); + position: sticky; + top: 0; + z-index: 5; + background: var(--bfc-bg); + flex-shrink: 0; +} + +.bfc-week-body { + display: flex; + flex-shrink: 0; +} +.bfc-week-header-time { + width: 60px; + flex-shrink: 0; + border-inline-end: 1px solid var(--bfc-border); +} +.bfc-week-header-day { + flex: 1; + text-align: center; + padding: 8px 4px; + border-right: 1px solid var(--bfc-border); + font-size: 13px; +} +.bfc-week-header-day:last-child { border-right: none; } +.bfc-week-header-day-name { color: var(--bfc-text-secondary); font-weight: 500; } +.bfc-week-header-day-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + font-weight: 700; + font-size: 15px; +} +.bfc-week-header-day-num.today { + background: var(--bfc-primary); + color: var(--bfc-primary-text); +} + +.bfc-week-day-col { + flex: 1; + position: relative; + border-right: 1px solid var(--bfc-border); + min-height: calc(24 * var(--bfc-hour-height)); + overflow: hidden; +} +.bfc-week-day-col:last-child { border-right: none; } + +/* ===== Year View ===== */ +.bfc-year { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); + gap: 16px; + padding: 16px; + overflow-y: auto; + height: 100%; +} + +.bfc-year-month { + border: 1px solid var(--bfc-border); + border-radius: var(--bfc-radius); + padding: 12px; + min-width: 0; +} +.bfc-year-month-title { + font-weight: 600; + font-size: 14px; + margin-bottom: 8px; + cursor: pointer; + transition: color 0.15s; +} +.bfc-year-month-title:hover { color: var(--bfc-primary); } + +.bfc-year-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + margin-bottom: 4px; +} +.bfc-year-weekday { + text-align: center; + font-size: 10px; + font-weight: 600; + color: var(--bfc-text-muted); + text-transform: uppercase; +} + +.bfc-year-days { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 1px; +} +.bfc-year-day { + text-align: center; + padding: 3px 0; + font-size: 11px; + border-radius: 4px; + cursor: default; + position: relative; +} +.bfc-year-day.other-month { opacity: 0.3; } +.bfc-year-day-number { + width: 26px; + height: 26px; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + border-radius: 50%; +} +.bfc-year-day-number.today { + background: var(--bfc-primary); + color: var(--bfc-primary-text); + font-weight: 700; +} +.bfc-year-day.has-events { cursor: pointer; font-weight: 600; } +.bfc-year-day.has-events:hover { background: var(--bfc-bg-hover); } + +.bfc-year-day-bullets { + display: flex; + gap: 2px; + justify-content: center; + margin-top: 1px; +} + +/* ===== Agenda View ===== */ +.bfc-agenda { + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; +} + +.bfc-agenda-search { + padding: 12px 16px; + border-bottom: 1px solid var(--bfc-border); +} +.bfc-agenda-search input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--bfc-border); + border-radius: var(--bfc-radius-sm); + background: var(--bfc-bg); + color: var(--bfc-text); + font-size: 13px; + outline: none; +} +.bfc-agenda-search input:focus { border-color: var(--bfc-primary); box-shadow: 0 0 0 2px color-mix(in srgb, var(--bfc-primary) 15%, transparent); } + +.bfc-agenda-group { + border-bottom: 1px solid var(--bfc-border); +} +.bfc-agenda-group-title { + padding: 8px 16px; + font-size: 12px; + font-weight: 600; + color: var(--bfc-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + background: var(--bfc-bg-secondary); + position: sticky; + top: 0; + z-index: 1; +} + +.bfc-agenda-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + cursor: pointer; + transition: background 0.1s; +} +.bfc-agenda-item:hover { background: var(--bfc-bg-hover); } + +.bfc-agenda-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + color: #fff; + flex-shrink: 0; +} + +.bfc-agenda-info { flex: 1; min-width: 0; } +.bfc-agenda-title { font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.bfc-agenda-desc { font-size: 12px; color: var(--bfc-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.bfc-agenda-time { font-size: 12px; color: var(--bfc-text-muted); white-space: nowrap; flex-shrink: 0; } + +.bfc-agenda-empty { + padding: 40px 16px; + text-align: center; + color: var(--bfc-text-muted); + font-size: 14px; +} + +/* ===== Dialogs / Modals ===== */ +.bfc-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.4); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + animation: bfcFadeIn 0.15s ease; +} + +.bfc-dialog { + background: var(--bfc-bg); + border-radius: var(--bfc-radius); + box-shadow: var(--bfc-shadow-lg); + width: 100%; + max-width: 480px; + max-height: 90vh; + overflow-y: auto; + animation: bfcSlideUp 0.2s ease; +} + +@keyframes bfcSlideUp { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +.bfc-dialog-header { + padding: 16px 20px; + border-bottom: 1px solid var(--bfc-border); +} +.bfc-dialog-header h3 { + margin: 0; + font-size: 16px; + font-weight: 600; +} +.bfc-dialog-header p { + margin: 4px 0 0; + font-size: 13px; + color: var(--bfc-text-secondary); +} +.bfc-dialog-header-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} +.bfc-dialog-header-row h3 { flex: 1; min-width: 0; } +.bfc-dialog-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + background: none; + border-radius: var(--bfc-radius-sm); + color: var(--bfc-text-muted); + cursor: pointer; + flex-shrink: 0; + transition: background 0.1s, color 0.1s; +} +.bfc-dialog-close-btn:hover { + background: var(--bfc-bg-hover); + color: var(--bfc-text); +} +.bfc-dialog-body { padding: 16px 20px; } +.bfc-dialog-footer { + padding: 12px 20px; + border-top: 1px solid var(--bfc-border); + display: flex; + justify-content: flex-end; + gap: 8px; +} + +/* ===== Form fields ===== */ +.bfc-field { margin-bottom: 14px; } +.bfc-field label { + display: block; + font-size: 13px; + font-weight: 500; + margin-bottom: 4px; + color: var(--bfc-text); +} +.bfc-field input, .bfc-field select, .bfc-field textarea { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--bfc-border); + border-radius: var(--bfc-radius-sm); + background: var(--bfc-bg); + color: var(--bfc-text); + font-size: 13px; + outline: none; + box-sizing: border-box; +} +.bfc-field input:focus, .bfc-field select:focus, .bfc-field textarea:focus { + border-color: var(--bfc-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--bfc-primary) 15%, transparent); +} +.bfc-field textarea { resize: vertical; min-height: 60px; } +.bfc-field .bfc-field-error { color: var(--bfc-danger); font-size: 12px; margin-top: 2px; } + +/* ===== Custom Date-Time Picker ===== */ +.bfc-dtp { + position: relative; +} + +.bfc-dtp-trigger { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--bfc-border); + border-radius: var(--bfc-radius-sm); + background: var(--bfc-bg); + color: var(--bfc-text); + font-size: 13px; + text-align: left; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.bfc-dtp-trigger:hover { + background: var(--bfc-bg-hover); +} + +.bfc-dtp-trigger-icon { + color: var(--bfc-text-muted); + font-size: 10px; +} + +.bfc-dtp-panel { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + z-index: 20; + border: 1px solid var(--bfc-border); + border-radius: var(--bfc-radius-sm); + background: var(--bfc-bg); + padding: 8px; + box-shadow: var(--bfc-shadow-lg); +} + +.bfc-dtp-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.bfc-dtp-month-label { + font-size: 12px; + font-weight: 600; + color: var(--bfc-text); +} + +.bfc-dtp-nav-btn { + border: 1px solid var(--bfc-border); + background: var(--bfc-bg); + color: var(--bfc-text); + width: 24px; + height: 24px; + border-radius: var(--bfc-radius-sm); + line-height: 1; + cursor: pointer; +} + +.bfc-dtp-nav-btn:hover { + background: var(--bfc-bg-hover); +} + +.bfc-dtp-weekdays { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 4px; + margin-bottom: 4px; +} + +.bfc-dtp-weekdays span { + text-align: center; + font-size: 11px; + color: var(--bfc-text-muted); +} + +.bfc-dtp-days { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 4px; +} + +.bfc-dtp-day { + border: 1px solid transparent; + background: transparent; + color: var(--bfc-text); + border-radius: 6px; + height: 28px; + font-size: 12px; + cursor: pointer; +} + +.bfc-dtp-day:hover { + background: var(--bfc-bg-hover); +} + +.bfc-dtp-day-muted { + color: var(--bfc-text-muted); +} + +.bfc-dtp-day-selected { + background: var(--bfc-primary); + color: #fff; +} + +.bfc-dtp-day-selected:hover { + background: var(--bfc-primary); +} + +.bfc-dtp-time { + margin-top: 8px; + display: flex; + align-items: center; + gap: 6px; +} + +.bfc-dtp-time select { + width: auto; + min-width: 64px; +} + +/* Color picker in form */ +.bfc-color-picker { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.bfc-color-swatch { + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + transition: transform 0.1s, border-color 0.1s; + background: var(--bfc-evt-color, var(--bfc-primary)); +} +.bfc-color-swatch:hover { transform: scale(1.1); } +.bfc-color-swatch.selected { border-color: var(--bfc-text); transform: scale(1.15); } + +/* ===== Attendee Chips ===== */ +.bfc-attendees-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} +.bfc-attendee-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 8px 3px 4px; + background: var(--bfc-bg-hover); + border: 1px solid var(--bfc-border); + border-radius: 20px; + font-size: 12px; + font-weight: 500; + color: var(--bfc-text); +} +.bfc-attendee-chip-readonly { + padding: 3px 10px 3px 4px; +} +.bfc-attendee-chip-avatar { + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--bfc-primary); + color: #fff; + font-size: 10px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.bfc-attendee-chip-remove { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + padding: 0; + margin-left: 2px; + cursor: pointer; + color: var(--bfc-text-muted); + border-radius: 50%; + width: 16px; + height: 16px; + transition: background 0.1s, color 0.1s; +} +.bfc-attendee-chip-remove:hover { + background: var(--bfc-danger); + color: #fff; +} + +/* Attendee add row */ +.bfc-attendee-add-row { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.bfc-attendee-add-row input { + flex: 1; + min-width: 80px; + padding: 6px 8px; + border: 1px solid var(--bfc-border); + border-radius: var(--bfc-radius-sm); + background: var(--bfc-bg); + color: var(--bfc-text); + font-size: 12px; + outline: none; + box-sizing: border-box; +} +.bfc-attendee-add-row input:focus { + border-color: var(--bfc-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--bfc-primary) 15%, transparent); +} + +/* ===== Event Details Dialog ===== */ +.bfc-event-detail-row { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 12px; +} +.bfc-event-detail-icon { + width: 18px; + height: 18px; + color: var(--bfc-text-muted); + flex-shrink: 0; + margin-top: 1px; +} +.bfc-event-detail-label { + font-size: 12px; + color: var(--bfc-text-muted); + margin-bottom: 2px; +} +.bfc-event-detail-value { font-size: 13px; } + +.bfc-event-detail-swatch { + width: 18px; + height: 18px; + border-radius: 50%; + flex-shrink: 0; + margin-top: 1px; + border: 1px solid rgba(0, 0, 0, 0.12); + box-sizing: border-box; +} + +.bfc-event-detail-color { + display: inline-block; + margin-top: 4px; + padding: 4px 10px; + border-radius: var(--bfc-radius-sm); + border: 1px solid transparent; + font-weight: 500; +} + +/* ===== Settings Dropdown ===== */ +.bfc-dropdown { + position: relative; + display: inline-block; +} +.bfc-dropdown-menu { + position: absolute; + right: 0; + top: calc(100% + 4px); + background: var(--bfc-bg); + border: 1px solid var(--bfc-border); + border-radius: var(--bfc-radius); + box-shadow: var(--bfc-shadow-lg); + min-width: 240px; + z-index: 50; + padding: 6px; + animation: bfcSlideUp 0.15s ease; +} +.bfc-dropdown-label { + font-size: 12px; + font-weight: 600; + color: var(--bfc-text-secondary); + padding: 6px 10px 4px; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.bfc-dropdown-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border-radius: 4px; + font-size: 13px; + cursor: pointer; + transition: background 0.1s; +} +.bfc-dropdown-item:hover { background: var(--bfc-bg-hover); } +.bfc-dropdown-sep { + height: 1px; + background: var(--bfc-border); + margin: 4px 0; +} + +/* ===== Toggle Switch ===== */ +.bfc-toggle { + position: relative; + display: inline-flex; + width: 36px; + height: 20px; + cursor: pointer; +} +.bfc-toggle input { display: none; } +.bfc-toggle-track { + width: 100%; + height: 100%; + border-radius: 10px; + background: var(--bfc-border); + transition: background 0.2s; +} +.bfc-toggle input:checked + .bfc-toggle-track { background: var(--bfc-primary); } +.bfc-toggle-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + transition: transform 0.2s; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); +} +.bfc-toggle input:checked ~ .bfc-toggle-thumb { transform: translateX(16px); } + +/* ===== Filter row ===== */ +.bfc-filter-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +/* ===== Filter Select ===== */ +.bfc-filter-select { + padding: 6px 10px; + border: 1px solid var(--bfc-border); + border-radius: var(--bfc-radius-sm); + font-size: 12px; + font-weight: 500; + background: var(--bfc-bg); + color: var(--bfc-text-secondary); + outline: none; + min-width: 120px; +} +.bfc-filter-select-person { + min-width: 140px; + max-width: 200px; +} +.bfc-filter-select:focus { + border-color: var(--bfc-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--bfc-primary) 15%, transparent); +} + +/* ===== User Select ===== */ +.bfc-user-select { + position: relative; +} +.bfc-user-select-menu { + position: absolute; + right: 0; + top: calc(100% + 4px); + background: var(--bfc-bg); + border: 1px solid var(--bfc-border); + border-radius: var(--bfc-radius); + box-shadow: var(--bfc-shadow-lg); + min-width: 200px; + z-index: 50; + padding: 4px; + animation: bfcSlideUp 0.15s ease; +} +.bfc-user-option { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 4px; + font-size: 13px; + cursor: pointer; + transition: background 0.1s; +} +.bfc-user-option:hover { background: var(--bfc-bg-hover); } +.bfc-user-option.active { background: var(--bfc-primary); color: var(--bfc-primary-text); } + +/* ===== Drag feedback ===== */ +.bfc-dragging { opacity: 0.5; } +.bfc-drop-target { background: color-mix(in srgb, var(--bfc-primary) 8%, transparent) !important; } + +/* ===== Mini calendar (day picker sidebar) ===== */ +.bfc-mini-calendar { width: 100%; } +.bfc-mini-calendar table { width: 100%; border-collapse: collapse; } +.bfc-mini-calendar th { + font-size: 11px; + font-weight: 600; + color: var(--bfc-text-muted); + padding: 4px; + text-align: center; +} +.bfc-mini-calendar td { + text-align: center; + padding: 2px; +} +.bfc-mini-calendar-day { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + font-size: 12px; + cursor: pointer; + transition: background 0.1s; +} +.bfc-mini-calendar-day:hover { background: var(--bfc-bg-hover); } +.bfc-mini-calendar-day.today { background: var(--bfc-primary); color: var(--bfc-primary-text); } +.bfc-mini-calendar-day.selected { background: var(--bfc-primary); color: var(--bfc-primary-text); opacity: 0.8; } +.bfc-mini-calendar-day.other-month { opacity: 0.3; } + +/* ===== Happening Now (Day view sidebar) ===== */ +.bfc-happening-now { background: var(--bfc-bg); border-radius: var(--bfc-radius); padding: 12px; border: 1px solid var(--bfc-border); } +.bfc-happening-now-title { font-size: 13px; font-weight: 600; color: var(--bfc-primary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; } +.bfc-happening-now-empty { font-size: 12px; color: var(--bfc-text-muted); } + +.bfc-happening-event { + display: block; + width: 100%; + padding: 8px; + border-radius: var(--bfc-radius-sm); + margin-bottom: 6px; + border: 1px solid var(--bfc-border); + background: var(--bfc-bg); + color: inherit; + font: inherit; + text-align: start; + cursor: pointer; + transition: background 0.1s; +} +.bfc-happening-event:hover { background: var(--bfc-bg-hover); } +.bfc-happening-event:focus-visible { + outline: 2px solid var(--bfc-primary); + outline-offset: 2px; +} +[dir="rtl"] .bfc-happening-event { text-align: right; } +.bfc-happening-event-title { font-weight: 600; font-size: 13px; margin-bottom: 4px; } +.bfc-happening-event-meta { font-size: 12px; color: var(--bfc-text-secondary); display: flex; align-items: center; gap: 4px; } + +/* ===== Toast ===== */ +.bfc-toast-container { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 200; + display: flex; + flex-direction: column; + gap: 8px; +} +.bfc-toast { + padding: 12px 16px; + background: var(--bfc-bg); + border: 1px solid var(--bfc-border); + border-radius: var(--bfc-radius); + box-shadow: var(--bfc-shadow-lg); + font-size: 13px; + animation: bfcSlideUp 0.2s ease; + display: flex; + align-items: center; + gap: 8px; + max-width: 320px; +} +.bfc-toast.success { border-left: 3px solid #22c55e; } +.bfc-toast.error { border-left: 3px solid #ef4444; } + +/* ===== SVG Icon sizes ===== */ +.bfc-icon { width: 16px; height: 16px; display: inline-flex; align-items: center; justify-content: center; } +.bfc-icon svg { width: 100%; height: 100%; } + +/* ===== Responsive ===== */ +@media (max-width: 768px) { + .bfc-header { flex-direction: column; align-items: stretch; } + .bfc-header-left, .bfc-header-center, .bfc-header-right { justify-content: center; } + .bfc-day-sidebar { display: none; } + .bfc-year { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; padding: 8px; } + .bfc-week-mobile-warning { display: block; } +} + +@media (max-width: 640px) { + .bfc-year { grid-template-columns: 1fr; } +} + +@media (min-width: 769px) { + .bfc-week-mobile-warning { display: none; } +} + +.bfc-week-mobile-warning { + padding: 12px 16px; + background: color-mix(in srgb, #{$clr-wrn} 18%, var(--bfc-bg)); + color: #{$clr-wrn-dark}; + font-size: 13px; + text-align: center; + border-bottom: 1px solid color-mix(in srgb, #{$clr-wrn} 35%, var(--bfc-bg)); +} + +/* ===== Droppable area hover states ===== */ +.bfc-droppable { transition: background 0.1s; } +.bfc-droppable.drag-over { background: color-mix(in srgb, var(--bfc-primary) 8%, transparent); } +.bfc-droppable.bfc-drop-preview-hour { + background: linear-gradient( + to bottom, + color-mix(in srgb, var(--bfc-primary) 16%, transparent) 0%, + color-mix(in srgb, var(--bfc-primary) 16%, transparent) 50%, + transparent 50%, + transparent 100% + ); + outline: 1px solid color-mix(in srgb, var(--bfc-primary) 45%, transparent); + outline-offset: -1px; + z-index: 6; +} +.bfc-droppable.bfc-drop-preview-half { + background: color-mix(in srgb, var(--bfc-primary) 16%, transparent); + outline: 1px solid color-mix(in srgb, var(--bfc-primary) 45%, transparent); + outline-offset: -1px; + z-index: 7; + opacity: 1; +} + +/* ===== Cell "add event" hover hint ===== */ +.bfc-hour-row { cursor: pointer; transition: background 0.1s; } +.bfc-hour-row:hover { background: var(--bfc-bg-hover); } + +.bfc-cell-add-hint { + opacity: 0; + transition: opacity 0.15s; + pointer-events: none; + display: flex; + align-items: center; + gap: 3px; + color: var(--bfc-text-muted); + font-size: 11px; + font-weight: 500; + user-select: none; +} +.bfc-month-cell:hover .bfc-cell-add-hint, +.bfc-hour-row:hover .bfc-cell-add-hint { + opacity: 1; +} +.bfc-cell-add-hint-month { + position: absolute; + top: 5px; + right: 6px; +} +.bfc-cell-add-hint-hour { + position: absolute; + top: 4px; + right: 8px; + z-index: 0; +} + +[dir="rtl"] .bfc-cell-add-hint-month { + right: auto; + left: 6px; +} + +[dir="rtl"] .bfc-cell-add-hint-hour { + right: auto; + left: 8px; +} + +/* ===== Scrollbar styling ===== */ +.bfc-body ::-webkit-scrollbar, +.bfc-week-scroll::-webkit-scrollbar { width: 8px; } +.bfc-body ::-webkit-scrollbar-track, +.bfc-week-scroll::-webkit-scrollbar-track { background: transparent; } +.bfc-body ::-webkit-scrollbar-thumb, +.bfc-week-scroll::-webkit-scrollbar-thumb { background: var(--bfc-border); border-radius: 4px; } +.bfc-body ::-webkit-scrollbar-thumb:hover, +.bfc-week-scroll::-webkit-scrollbar-thumb:hover { background: var(--bfc-text-muted); } + +/* ===== RTL (Right-to-Left) support ===== */ + +/* Flip horizontal navigation arrows (prev/next) for RTL */ +[dir="rtl"] .bfc-btn-nav svg { transform: scaleX(-1); } + +/* Header zones: reverse the logical order of sections */ +/* [dir="rtl"] .bfc-header { flex-direction: row-reverse; } +[dir="rtl"] .bfc-header-left { flex: 1; justify-content: flex-end; } +[dir="rtl"] .bfc-header-right { flex: 1; justify-content: flex-start; } */ + +/* Month grid: columns flow right-to-left */ +[dir="rtl"] .bfc-month-header { direction: rtl; } +[dir="rtl"] .bfc-month-grid { direction: rtl; } + +/* Week header + timegrid */ +[dir="rtl"] .bfc-week-header { direction: rtl; } +[dir="rtl"] .bfc-week-layout { direction: rtl; } +[dir="rtl"] .bfc-week-scroll { direction: rtl; } +[dir="rtl"] .bfc-timegrid-days { direction: rtl; } + +/* Multi-day badge joins need mirrored corners/borders in RTL */ +[dir="rtl"] .bfc-event-badge.position-first { + border-radius: 0 4px 4px 0; + border-left: none; + border-right: 1px solid transparent; +} +[dir="rtl"] .bfc-event-badge.position-middle { + border-left: none; + border-right: none; +} +[dir="rtl"] .bfc-event-badge.position-last { + border-radius: 4px 0 0 4px; + border-right: none; + border-left: 1px solid transparent; +} + +/* Visual last-column borders in RTL are on the first DOM child */ +[dir="rtl"] .bfc-month-cell:nth-child(7n) { border-right: 1px solid var(--bfc-border); } +[dir="rtl"] .bfc-month-cell:nth-child(7n + 1) { border-right: none; } +[dir="rtl"] .bfc-week-header-day:last-child { border-right: 1px solid var(--bfc-border); } +[dir="rtl"] .bfc-week-header-day:first-child { border-right: none; } +[dir="rtl"] .bfc-week-day-col:last-child { border-right: 1px solid var(--bfc-border); } +[dir="rtl"] .bfc-week-day-col:first-child { border-right: none; } + +/* Day view: sidebar moves to the left side */ +[dir="rtl"] .bfc-day-layout { direction: rtl; } +[dir="rtl"] .bfc-day-main > .bfc-timegrid-wrapper { + direction: rtl; +} + +/* Year view mini-grids */ +[dir="rtl"] .bfc-year { direction: rtl; } +[dir="rtl"] .bfc-year-weekdays { direction: rtl; } +[dir="rtl"] .bfc-year-days { direction: rtl; } + +/* Agenda view */ +[dir="rtl"] .bfc-agenda-item { direction: rtl; } +[dir="rtl"] .bfc-agenda-time { margin-left: 0; margin-right: auto; } +[dir="rtl"] .bfc-agenda-info { text-align: right; } +[dir="rtl"] .bfc-timeline-label { left: auto; right: -54px; } + +/* Settings dropdown should open toward the viewport in RTL */ +[dir="rtl"] .bfc-dropdown-menu { + right: auto; + left: 0; +} + +/* Mini-calendar */ +[dir="rtl"] .bfc-mini-calendar { direction: rtl; } + +/* Dialog / toast */ +[dir="rtl"] .bfc-dialog { direction: rtl; } +[dir="rtl"] .bfc-toast { direction: rtl; } + +/* Text alignment helpers */ +[dir="rtl"] .bfc-day-header { direction: rtl; } +[dir="rtl"] .bfc-happening-now { direction: rtl; } + + +/* ===== Mode Tabs (Event ⇄ Timeline) ===== */ +.bfc-mode-tabs { + display: inline-flex; + background: var(--bfc-bg-secondary); + border-radius: var(--bfc-radius-sm); + padding: 2px; + gap: 2px; + margin-inline-end: 8px; +} +.bfc-mode-tab { + padding: 5px 12px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--bfc-text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} +.bfc-mode-tab:hover { color: var(--bfc-text); background: var(--bfc-bg-hover); } +.bfc-mode-tab.active { + background: var(--bfc-bg); + color: var(--bfc-text); + box-shadow: var(--bfc-shadow); +} + +/* ===== Timeline Mode (resource × time grid) ===== */ +.bfc-tl-layout { + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bfc-bg); +} + +.bfc-tl-empty { + padding: 40px 16px; + text-align: center; + color: var(--bfc-text-muted); + font-size: 14px; +} + +.bfc-tl-scroll { + flex: 1; + min-height: 0; + overflow: auto; + position: relative; +} + +.bfc-tl-grid { + display: flex; + flex-direction: column; + /* width is set inline (resource gutter + total time width) */ + align-content: start; + min-height: 100%; +} + +.bfc-tl-header-row { + display: flex; + position: sticky; + top: 0; + z-index: 5; + /* Width set inline (resource gutter + total time width). + Sticky on the row itself keeps both the corner and the time header pinned to the top. */ +} + +.bfc-tl-body-row { + display: flex; + /* Width set inline so each row's containing block spans the full timeline width. + This is what allows the sticky resource cell to stay pinned across the entire horizontal scroll. */ +} + +.bfc-tl-corner { + position: sticky; + left: 0; + z-index: 6; + background: var(--bfc-bg-secondary); + border-bottom: 1px solid var(--bfc-border); + border-inline-end: 1px solid var(--bfc-border); + padding: 10px 12px; + font-size: 12px; + font-weight: 600; + color: var(--bfc-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + box-sizing: border-box; + flex-shrink: 0; + display: flex; + align-items: center; +} + +.bfc-tl-time-header { + /* Sticky-top is provided by the header row; keep this element non-sticky so the resource + gutter can stay pinned on top of it during horizontal scroll. */ + z-index: 4; + background: var(--bfc-bg-secondary); + border-bottom: 1px solid var(--bfc-border); + box-sizing: border-box; + flex-shrink: 0; + display: flex; + flex-direction: column; +} + +.bfc-tl-time-row { + display: flex; + box-sizing: border-box; +} + +.bfc-tl-time-row-days { + border-bottom: 1px solid var(--bfc-border); + height: 32px; +} + +.bfc-tl-time-row-slots { + height: 36px; +} + +.bfc-tl-day-header { + flex-shrink: 0; + box-sizing: border-box; + border-inline-end: 1px solid var(--bfc-border); + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: var(--bfc-text); + background: var(--bfc-bg-secondary); + padding: 0 8px; +} + +.bfc-tl-day-header.today { + color: var(--bfc-primary); +} + +.bfc-tl-day-header-name { + font-weight: 600; +} + +.bfc-tl-day-header-date { + color: var(--bfc-text-muted); + font-weight: 500; +} + +.bfc-tl-time-cell { + flex-shrink: 0; + box-sizing: border-box; + border-inline-end: 1px solid var(--bfc-border); + display: flex; + align-items: center; + justify-content: flex-start; + padding-inline-start: 6px; +} + +.bfc-tl-time-cell.today { + background: color-mix(in srgb, var(--bfc-primary) 8%, transparent); +} + +.bfc-tl-time-cell-label { + font-size: 11px; + font-weight: 500; + color: var(--bfc-text-muted); + white-space: nowrap; +} + +.bfc-tl-resource-cell { + position: sticky; + left: 0; + z-index: 4; + background: var(--bfc-bg); + border-bottom: 1px solid var(--bfc-border); + border-inline-end: 1px solid var(--bfc-border); + padding: 8px 12px; + box-sizing: border-box; + flex-shrink: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + min-width: 0; +} + +.bfc-tl-resource-cell-unassigned { + background: var(--bfc-bg-secondary); +} + +.bfc-tl-resource-title { + font-size: 13px; + font-weight: 600; + color: var(--bfc-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bfc-tl-resource-subtitle { + font-size: 11px; + color: var(--bfc-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bfc-tl-row { + position: relative; + border-bottom: 1px solid var(--bfc-border); + box-sizing: border-box; + background: var(--bfc-bg); + flex-shrink: 0; +} + +.bfc-tl-cell { + position: absolute; + top: 0; + bottom: 0; + box-sizing: border-box; + border-inline-end: 1px solid var(--bfc-border); + cursor: pointer; + transition: background 0.1s; +} + +.bfc-tl-cell:hover { background: var(--bfc-bg-hover); } +.bfc-tl-cell:hover .bfc-cell-add-hint { opacity: 1; } + +.bfc-tl-cell-half { + position: absolute; + top: 0; + bottom: 0; + right: 0; + width: 50%; + border-inline-start: 1px dashed var(--bfc-border); + opacity: 0.5; +} + +.bfc-tl-cell-day { + /* Day-precision cell (timeline month view) - no half-hour split. */ +} + +.bfc-tl-drop-preview { + background: var(--bfc-resize-glow); +} + +.bfc-tl-event-anchor { + position: absolute; + z-index: 3; +} + +.bfc-timeline-event { + position: relative; + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 4px 8px; + border-radius: 3px; + border: 1px solid transparent; + font-size: 12px; + cursor: grab; + overflow: hidden; + user-select: none; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + line-height: 1.2; + transition: filter 0.1s, box-shadow 0.15s; +} + +.bfc-timeline-event:hover { + filter: brightness(0.95); + box-shadow: var(--bfc-shadow); + z-index: 5; +} + +.bfc-timeline-event-pass-through { pointer-events: none; opacity: 0.55; } + +/* Horizontal resize handles for timeline event blocks */ +.bfc-resize-handle-h { + position: absolute; + top: 0; + bottom: 0; + width: 10px; + cursor: ew-resize; + z-index: 3; + touch-action: none; + border-radius: 3px; + transition: background 0.15s ease, box-shadow 0.15s ease; +} +.bfc-resize-handle-h::after { + content: ""; + position: absolute; + top: 50%; + transform: translateY(-50%); + height: 28px; + width: 3px; + border-radius: 999px; + background: var(--bfc-resize-handle-line); + opacity: 0; + transition: opacity 0.15s ease, height 0.15s ease, box-shadow 0.15s ease; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); +} +.bfc-resize-handle-start { left: -5px; } +.bfc-resize-handle-start::after { left: 3px; } +.bfc-resize-handle-end { right: -5px; } +.bfc-resize-handle-end::after { right: 3px; } +.bfc-resize-handle-h:hover { background: var(--bfc-resize-glow); } +.bfc-resize-handle-h:hover::after { + opacity: 0.85; + height: 36px; +} + +.bfc-timeline-event-resizing { + z-index: 12; + cursor: ew-resize; + filter: brightness(1.02) saturate(1.05); + will-change: transform, width; + border-color: var(--bfc-resize-ring) !important; + box-shadow: + 0 0 0 2px var(--bfc-resize-ring), + 0 0 0 5px var(--bfc-resize-glow), + 0 12px 28px rgba(15, 23, 42, 0.18), + var(--bfc-shadow); + transition: box-shadow 0.2s ease, filter 0.2s ease, border-color 0.2s ease; +} +.bfc-timeline-event-resizing .bfc-resize-handle-start { + background: linear-gradient(to right, var(--bfc-resize-glow), transparent); +} +.bfc-timeline-event-resizing .bfc-resize-handle-end { + background: linear-gradient(to left, var(--bfc-resize-glow), transparent); +} +.bfc-timeline-event-resizing .bfc-resize-handle-h::after { + opacity: 1; + height: 40px; + box-shadow: 0 0 0 2px var(--bfc-resize-glow), 0 2px 6px color-mix(in srgb, var(--bfc-primary) 35%, transparent); +} +.bfc-timeline-event-resizing.bfc-resize-dir-start .bfc-resize-handle-start::after, +.bfc-timeline-event-resizing.bfc-resize-dir-end .bfc-resize-handle-end::after { + width: 4px; + height: 44px; + box-shadow: 0 0 0 3px var(--bfc-resize-glow), 0 2px 10px color-mix(in srgb, var(--bfc-primary) 45%, transparent); +} + +.bfc-timeline-event-row { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.bfc-timeline-event-title { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1; +} + +.bfc-timeline-event-time { + font-size: 11px; + opacity: 0.85; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bfc-timeline-event.dot-variant { + background: var(--bfc-bg-secondary); + color: var(--bfc-text); + border-color: var(--bfc-border); +} + +/* RTL: keep the resource gutter on the leading edge. + With direction:rtl on the flex rows, the inline-start edge maps to the right side of + the scroll container, so the corner and resource cells must pin to right:0 instead of left:0. */ +[dir="rtl"] .bfc-tl-grid { direction: rtl; } +[dir="rtl"] .bfc-tl-time-header { direction: rtl; } +[dir="rtl"] .bfc-tl-row { direction: rtl; } +[dir="rtl"] .bfc-tl-corner { left: auto; right: 0; } +[dir="rtl"] .bfc-tl-resource-cell { left: auto; right: 0; } + +/* Responsive: shrink resource gutter on small screens */ +@media (max-width: 768px) { + .bfc-tl-corner, + .bfc-tl-resource-cell { padding-inline: 8px; } + .bfc-tl-resource-title { font-size: 12px; } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.ts new file mode 100644 index 0000000000..99b66532b8 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.ts @@ -0,0 +1,224 @@ +namespace BitBlazorUI { + export class FullCalendar { + public static scrollToHour(elementId: string, hour: number, pixelsPerHour: number | null) { + const el = document.getElementById(elementId); + if (!el) return; + const pxPerHour = pixelsPerHour ?? 96; + const top = hour * pxPerHour; + if (typeof el.scrollTo === "function") { + el.scrollTo({ top: top, behavior: "auto" }); + } else { + el.scrollTop = top; + } + } + + /** + * Scrolls the timeline scroll container horizontally so the element marked with + * data-bfc-tl-scroll-target="true" sits just past the sticky resource gutter. + * Direction-aware (works in both LTR and RTL layouts). Returns true if a target was + * found and scroll was applied (or already in position), false otherwise. + */ + public static scrollTimelineToTarget(scrollContainerId: string): boolean { + const container = document.getElementById(scrollContainerId); + if (!container) return false; + const target = container.querySelector('[data-bfc-tl-scroll-target="true"]'); + if (!target) return false; + + const gutter = container.querySelector('.bfc-tl-corner'); + const gutterWidth = gutter ? gutter.getBoundingClientRect().width : 0; + + const cRect = container.getBoundingClientRect(); + const tRect = target.getBoundingClientRect(); + const isRtl = getComputedStyle(container).direction === "rtl"; + + const delta = isRtl + ? tRect.right - (cRect.right - gutterWidth) + : tRect.left - (cRect.left + gutterWidth); + if (Math.abs(delta) >= 0.5) { + container.scrollLeft += delta; + } + return true; + } + + public static scrollAgendaToDate(scrollContainerId: string, dateIso: string) { + const container = document.getElementById(scrollContainerId); + if (!container) return; + const nodes = container.querySelectorAll('[data-agenda-date="' + dateIso + '"]'); + if (!nodes.length) return; + + let target = nodes[0]; + let bestTop = target.getBoundingClientRect().top; + for (let i = 1; i < nodes.length; i++) { + const top = nodes[i].getBoundingClientRect().top; + if (top < bestTop) { + bestTop = top; + target = nodes[i]; + } + } + + const containerRect = container.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const scrollTop = container.scrollTop + (targetRect.top - containerRect.top); + if (typeof container.scrollTo === "function") { + container.scrollTo({ top: scrollTop, behavior: "auto" }); + } else { + container.scrollTop = scrollTop; + } + } + + /** + * Pointer resize for event blocks. Matches the idea of the reference calendar + * (re-resizable client-side updates): coalesce pointer moves to animation frames, + * capture the pointer, and await resize-start before tracking moves so Blazor state is ready. + */ + public static initResize(dotNetRef: DotNetObject, elementId: string, direction: string) { + const el = document.getElementById(elementId); + if (!el) return; + + const pixelsPerHour = 96; + const minPerPixel = 60 / pixelsPerHour; + + el.addEventListener("pointerdown", async (e: PointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + + const startY = e.clientY; + let latestY = startY; + let rafId: number | null = null; + let activePointerId: number | null = e.pointerId; + let ended = false; + + try { + el.setPointerCapture(e.pointerId); + } catch { /* older browsers */ } + + await dotNetRef.invokeMethodAsync("OnResizeStart", direction); + + const flushMove = () => { + rafId = null; + const deltaMinutes = Math.round((latestY - startY) * minPerPixel); + return dotNetRef.invokeMethodAsync("OnResizeMove", direction, deltaMinutes); + }; + + const onPointerMove = (ev: PointerEvent) => { + latestY = ev.clientY; + if (rafId == null) { + rafId = requestAnimationFrame(() => { + void flushMove(); + }); + } + }; + + const endResize = async () => { + if (ended) return; + ended = true; + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", endResize); + document.removeEventListener("pointercancel", endResize); + + if (rafId != null) { + cancelAnimationFrame(rafId); + rafId = null; + } + const deltaMinutes = Math.round((latestY - startY) * minPerPixel); + await dotNetRef.invokeMethodAsync("OnResizeMove", direction, deltaMinutes); + + try { + if (activePointerId != null && typeof el.releasePointerCapture === "function") + el.releasePointerCapture(activePointerId); + } catch { } + + activePointerId = null; + await dotNetRef.invokeMethodAsync("OnResizeEnd"); + }; + + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", endResize); + document.addEventListener("pointercancel", endResize); + }); + } + + /** + * Pointer resize for timeline event blocks along the horizontal time axis. + * Sends raw pixel deltas to .NET; the C# side converts to minute deltas using the active + * column's pixels-per-minute so the same handler works for hour-precision (day/week + * timelines) and day-precision (month timeline). Events are always placed with absolute + * `left:` from the left edge of the row, so a positive clientX delta always means + * "later in time" regardless of writing direction. + * direction is "start" (left edge of the event) or "end" (right edge of the event). + */ + public static initResizeHorizontal(dotNetRef: DotNetObject, elementId: string, direction: string) { + const el = document.getElementById(elementId); + if (!el) return; + + el.addEventListener("pointerdown", async (e: PointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + + const startX = e.clientX; + let latestX = startX; + let rafId: number | null = null; + let activePointerId: number | null = e.pointerId; + let ended = false; + + try { el.setPointerCapture(e.pointerId); } catch { /* older browsers */ } + + await dotNetRef.invokeMethodAsync("OnResizeStart", direction); + + const flushMove = () => { + rafId = null; + const deltaPx = latestX - startX; + return dotNetRef.invokeMethodAsync("OnResizeMove", direction, deltaPx); + }; + + const onPointerMove = (ev: PointerEvent) => { + latestX = ev.clientX; + if (rafId == null) { + rafId = requestAnimationFrame(() => { void flushMove(); }); + } + }; + + const endResize = async () => { + if (ended) return; + ended = true; + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", endResize); + document.removeEventListener("pointercancel", endResize); + + if (rafId != null) { + cancelAnimationFrame(rafId); + rafId = null; + } + const deltaPx = latestX - startX; + await dotNetRef.invokeMethodAsync("OnResizeMove", direction, deltaPx); + + try { + if (activePointerId != null && typeof el.releasePointerCapture === "function") + el.releasePointerCapture(activePointerId); + } catch { } + + activePointerId = null; + await dotNetRef.invokeMethodAsync("OnResizeEnd"); + }; + + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", endResize); + document.addEventListener("pointercancel", endResize); + }); + } + + public static isMobile(): boolean { + return window.innerWidth <= 768; + } + + public static getLocalStorage(key: string): string | null { + return localStorage.getItem(key); + } + + public static setLocalStorage(key: string, value: string) { + localStorage.setItem(key, value); + } + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcAddEditEventDialog.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcAddEditEventDialog.razor new file mode 100644 index 0000000000..49e037919b --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcAddEditEventDialog.razor @@ -0,0 +1,224 @@ +@namespace Bit.BlazorUI + +
+
+
+
+

@(_isEditing ? Texts.EditEventDialogTitle : Texts.AddEventDialogTitle)

+

@(_isEditing ? Texts.EditEventDialogSubtitle : Texts.AddEventDialogSubtitle)

+
+ +
+
+
+ + + @if (_errors.ContainsKey("title")) + { +
@_errors["title"]
+ } +
+ +
+ + +
+ +
+ + + @if (_errors.ContainsKey("endDate")) + { +
@_errors["endDate"]
+ } +
+ +
+ + +
+ +
+ + + @if (_errors.ContainsKey("description")) + { +
@_errors["description"]
+ } +
+ +
+ + @if (_attendees.Count > 0) + { +
+ @foreach (var a in _attendees) + { + var attendee = a; + + @attendee.Initials + @attendee.FullName + + + } +
+ } +
+ + + + +
+ @if (_errors.ContainsKey("attendee")) + { +
@_errors["attendee"]
+ } +
+
+ +
+
+ +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [Parameter] public BitFullCalendarEvent? ExistingEvent { get; set; } + [Parameter] public DateTime? StartDate { get; set; } + [Parameter] public int? StartHour { get; set; } + [Parameter] public EventCallback OnClose { get; set; } + + private bool _isEditing; + private string _title = ""; + private string _description = ""; + private DateTime _startDate; + private DateTime _endDate; + private string _color = BitFullCalendarColorScheme.FallbackColorId; + private List _attendees = []; + private string _newFirstName = ""; + private string _newLastName = ""; + private string _newId = ""; + private Dictionary _errors = new(); + + protected override void OnInitialized() + { + _isEditing = ExistingEvent != null; + var defaultColor = ColorScheme.Options.Count > 0 + ? ColorScheme.Options[0].Id + : BitFullCalendarColorScheme.FallbackColorId; + + if (_isEditing) + { + _title = ExistingEvent!.Title; + _description = ExistingEvent.Description; + _startDate = ExistingEvent.StartDate; + _endDate = ExistingEvent.EndDate; + _color = string.IsNullOrWhiteSpace(ExistingEvent.Color) ? defaultColor : ExistingEvent.Color; + _attendees = [.. ExistingEvent.Attendees]; + } + else + { + _color = defaultColor; + var baseDate = StartDate ?? State.SelectedDate; + _startDate = baseDate.Date.AddHours(StartHour ?? DateTime.Now.Hour); + _endDate = _startDate.AddMinutes(30); + } + } + + private void AddAttendee() + { + _errors.Remove("attendee"); + + if (string.IsNullOrWhiteSpace(_newFirstName) && string.IsNullOrWhiteSpace(_newLastName)) + { + _errors["attendee"] = Texts.ValidationAttendeeNameRequired; + return; + } + + _attendees.Add(new BitFullCalendarAttendee + { + FirstName = _newFirstName.Trim(), + LastName = _newLastName.Trim(), + Id = string.IsNullOrWhiteSpace(_newId) ? null : _newId.Trim() + }); + + _newFirstName = ""; + _newLastName = ""; + _newId = ""; + } + + private void RemoveAttendee(BitFullCalendarAttendee attendee) => _attendees.Remove(attendee); + + private Task OnStartDateChanged(DateTime value) + { + _startDate = value; + return Task.CompletedTask; + } + + private Task OnEndDateChanged(DateTime value) + { + _endDate = value; + return Task.CompletedTask; + } + + private async Task Submit() + { + _errors.Clear(); + if (string.IsNullOrWhiteSpace(_title)) + _errors["title"] = Texts.ValidationTitleRequired; + if (string.IsNullOrWhiteSpace(_description)) + _errors["description"] = Texts.ValidationDescriptionRequired; + if (_endDate <= _startDate) + _errors["endDate"] = Texts.ValidationEndAfterStart; + + if (_errors.Count > 0) return; + + var oldSnapshot = _isEditing && ExistingEvent is not null + ? BitFullCalendarChangeNotifier.CloneEvent(ExistingEvent) + : null; + + var ev = new BitFullCalendarEvent + { + Id = _isEditing ? ExistingEvent!.Id : Guid.NewGuid().ToString("N"), + Title = _title, + Description = _description, + StartDate = _startDate, + EndDate = _endDate, + Color = _color, + Resource = _isEditing ? ExistingEvent!.Resource : null, + Data = _isEditing ? ExistingEvent!.Data : null, + Attendees = [.. _attendees] + }; + + if (_isEditing) + State.UpdateEvent(ev); + else + State.AddEvent(ev); + + await Notifier.NotifyAsync(new BitFullCalendarChangeEventArgs + { + Event = BitFullCalendarChangeNotifier.CloneEvent(ev), + OldEvent = oldSnapshot, + Kind = _isEditing ? BitFullCalendarChangeKind.Edit : BitFullCalendarChangeKind.Add, + Source = BitFullCalendarChangeSource.Dialog + }); + + await OnClose.InvokeAsync(); + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcDateTimePicker.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcDateTimePicker.razor new file mode 100644 index 0000000000..e82a2b4bc4 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcDateTimePicker.razor @@ -0,0 +1,185 @@ +@using System.Globalization +@namespace Bit.BlazorUI + +
+ + + @if (_isOpen) + { +
+
+ +
@GetMonthYearLabel(_visibleMonthAnchor)
+ +
+ +
+ @foreach (var weekday in _weekdayHeaders) + { + @weekday + } +
+ +
+ @foreach (var day in BuildCalendarDays()) + { + var dayClass = GetDayCellClass(day); + + } +
+ +
+ + : + +
+
+ } +
+ +@code { + [Parameter] public DateTime Value { get; set; } + [Parameter] public EventCallback ValueChanged { get; set; } + [Parameter] public CultureInfo Culture { get; set; } = CultureInfo.CurrentCulture; + + private DateTime _visibleMonthAnchor; + private int _hour; + private int _minute; + private bool _isOpen; + private DateTime _lastSyncedDate = DateTime.MinValue; + private string[] _weekdayHeaders = []; + + protected override void OnParametersSet() + { + if (_lastSyncedDate != Value) + { + _hour = Value.Hour; + _minute = Value.Minute; + _visibleMonthAnchor = GetFirstDayOfMonth(Value); + _lastSyncedDate = Value; + } + + _weekdayHeaders = BuildWeekdayHeaders(); + } + + private Calendar ActiveCalendar => Culture.DateTimeFormat.Calendar; + + private string[] BuildWeekdayHeaders() + { + var source = Culture.DateTimeFormat.AbbreviatedDayNames; + var firstDay = (int)Culture.DateTimeFormat.FirstDayOfWeek; + return Enumerable.Range(0, 7) + .Select(i => source[(i + firstDay) % 7]) + .ToArray(); + } + + private void ToggleOpen() => _isOpen = !_isOpen; + + private void ShowPreviousMonth() + { + _visibleMonthAnchor = GetFirstDayOfMonth(ActiveCalendar.AddMonths(_visibleMonthAnchor, -1)); + } + + private void ShowNextMonth() + { + _visibleMonthAnchor = GetFirstDayOfMonth(ActiveCalendar.AddMonths(_visibleMonthAnchor, 1)); + } + + private async Task SelectDate(DateTime date) + { + var selected = new DateTime(date.Year, date.Month, date.Day, _hour, _minute, 0, date.Kind); + Value = selected; + _lastSyncedDate = selected; + _isOpen = false; + await ValueChanged.InvokeAsync(selected); + } + + private async Task OnTimeChanged() + { + var selected = new DateTime(Value.Year, Value.Month, Value.Day, _hour, _minute, 0, Value.Kind); + Value = selected; + _lastSyncedDate = selected; + await ValueChanged.InvokeAsync(selected); + } + + private void OnFocusOut(FocusEventArgs _) + { + _isOpen = false; + } + + private IEnumerable BuildCalendarDays() + { + var firstDayOfMonth = GetFirstDayOfMonth(_visibleMonthAnchor); + var firstDayOfWeek = Culture.DateTimeFormat.FirstDayOfWeek; + var shift = ((int)firstDayOfMonth.DayOfWeek - (int)firstDayOfWeek + 7) % 7; + var gridStart = firstDayOfMonth.AddDays(-shift); + + for (var i = 0; i < 42; i++) + { + var date = gridStart.AddDays(i); + yield return new CalendarDay( + Date: date, + Label: ActiveCalendar.GetDayOfMonth(date).ToString(Culture), + IsCurrentMonth: IsSameCalendarMonth(date, _visibleMonthAnchor), + IsSelected: date.Date == Value.Date); + } + } + + private string GetMonthYearLabel(DateTime date) + { + var month = ActiveCalendar.GetMonth(date); + var year = ActiveCalendar.GetYear(date); + var monthName = Culture.DateTimeFormat.GetMonthName(month); + return $"{monthName} {year.ToString(Culture)}"; + } + + private DateTime GetFirstDayOfMonth(DateTime date) + { + var year = ActiveCalendar.GetYear(date); + var month = ActiveCalendar.GetMonth(date); + return ActiveCalendar.ToDateTime(year, month, 1, 0, 0, 0, 0); + } + + private bool IsSameCalendarMonth(DateTime left, DateTime right) => + ActiveCalendar.GetYear(left) == ActiveCalendar.GetYear(right) + && ActiveCalendar.GetMonth(left) == ActiveCalendar.GetMonth(right); + + private string GetDayCellClass(CalendarDay day) + { + var classes = "bfc-dtp-day"; + if (!day.IsCurrentMonth) + classes += " bfc-dtp-day-muted"; + if (day.IsSelected) + classes += " bfc-dtp-day-selected"; + return classes; + } + + private string GetDisplayText() + { + var datePart = Value.ToString("d", Culture); + var timePart = Value.ToString("HH:mm", CultureInfo.InvariantCulture); + return $"{datePart} {timePart}"; + } + + private sealed record CalendarDay(DateTime Date, string Label, bool IsCurrentMonth, bool IsSelected); +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventDetailsDialog.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventDetailsDialog.razor new file mode 100644 index 0000000000..193bba3c5c --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventDetailsDialog.razor @@ -0,0 +1,117 @@ +@namespace Bit.BlazorUI + +
+
+
+

@Event.Title

+ +
+
+
+ +
+
@Texts.ColorLabel
+
@ColorScheme.GetLabel(Event.Color)
+
+
+
+ +
+
@Texts.AttendeesLabel
+ @if (Event.Attendees.Count == 0) + { +
@Texts.NoAttendeesText
+ } + else + { +
+ @foreach (var a in Event.Attendees) + { + + @a.Initials + @a.FullName + + } +
+ } +
+
+
+ +
+
@Texts.StartDateLabel
+
+ @Event.StartDate.ToString("dddd dd MMMM", State.Culture) @Texts.AtWord @BitFullCalendarHelpers.FormatTime(Event.StartDate, State.Use24HourFormat) +
+
+
+
+ +
+
@Texts.EndDateLabel
+
+ @Event.EndDate.ToString("dddd dd MMMM", State.Culture) @Texts.AtWord @BitFullCalendarHelpers.FormatTime(Event.EndDate, State.Use24HourFormat) +
+
+
+
+ +
+
@Texts.DescriptionLabel
+
@Event.Description
+
+
+
+ +
+
+ +@if (_showEdit) +{ + +} + +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [Parameter] public BitFullCalendarEvent Event { get; set; } = default!; + [Parameter] public EventCallback OnClose { get; set; } + + private bool _showEdit; + + private void EditAsync() + { + _showEdit = true; + } + + private void OnEditClose() + { + _showEdit = false; + OnClose.InvokeAsync(); + } + + private async Task Delete() + { + var snapshot = BitFullCalendarChangeNotifier.CloneEvent(Event); + State.RemoveEvent(Event.Id); + await Notifier.NotifyAsync(new BitFullCalendarChangeEventArgs + { + Event = snapshot, + OldEvent = snapshot, + Kind = BitFullCalendarChangeKind.Delete, + Source = BitFullCalendarChangeSource.Delete + }); + await OnClose.InvokeAsync(); + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventListDialog.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventListDialog.razor new file mode 100644 index 0000000000..d40c35fcec --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventListDialog.razor @@ -0,0 +1,60 @@ +@namespace Bit.BlazorUI + +
+
+
+

@Texts.EventListTitlePrefix @Date.ToString("dddd, MMM d, yyyy")

+

@Events.Count @Texts.EventListCountSuffix

+
+
+ @foreach (var ev in Events) + { +
+
+ @(ev.Attendees.Count > 0 ? ev.Attendees[0].Initials : "") +
+
+
@ev.Title
+
@ev.Description
+
+
+ @BitFullCalendarHelpers.FormatTime(ev.StartDate, State.Use24HourFormat) +
+
+ } +
+ +
+
+ +@if (_showDetails && _selectedEvent != null) +{ + +} + +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + [Parameter] public DateTime Date { get; set; } + [Parameter] public List Events { get; set; } = []; + [Parameter] public EventCallback OnClose { get; set; } + + private bool _showDetails; + private BitFullCalendarEvent? _selectedEvent; + + private async Task SelectEvent(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + _showDetails = true; + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/DragDrop/BitFcDraggableEvent.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/DragDrop/BitFcDraggableEvent.razor new file mode 100644 index 0000000000..e56b71e115 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/DragDrop/BitFcDraggableEvent.razor @@ -0,0 +1,24 @@ +@namespace Bit.BlazorUI + +
+ @ChildContent +
+ +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [Parameter] public BitFullCalendarEvent Event { get; set; } = default!; + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public string? Class { get; set; } + [Parameter] public EventCallback OnClick { get; set; } + + private bool _isDragged => State.IsDragging && State.DraggedEvent?.Id == Event.Id; + + private void OnDragStart() => State.StartDrag(Event); + private void OnDragEnd() => State.EndDrag(); +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/DragDrop/BitFcDroppableArea.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/DragDrop/BitFcDroppableArea.razor new file mode 100644 index 0000000000..6b75044d62 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/DragDrop/BitFcDroppableArea.razor @@ -0,0 +1,31 @@ +@namespace Bit.BlazorUI + +
+ @ChildContent +
+ +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [Parameter] public DateTime Date { get; set; } + [Parameter] public int? Hour { get; set; } + [Parameter] public int? Minute { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public string? Class { get; set; } + + private bool _isOver; + + private void OnDragOver() => _isOver = true; + private void OnDragLeave() => _isOver = false; + + private async Task OnDrop() + { + _isOver = false; + await Notifier.HandleDropAsync(Date, Hour, Minute); + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Enums/BitFullCalendarAgendaGroupBy.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Enums/BitFullCalendarAgendaGroupBy.cs new file mode 100644 index 0000000000..fe50bbe67b --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Enums/BitFullCalendarAgendaGroupBy.cs @@ -0,0 +1,8 @@ +namespace Bit.BlazorUI; + +public enum BitFullCalendarAgendaGroupBy +{ + Date, + Color +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Enums/BitFullCalendarBadgeVariant.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Enums/BitFullCalendarBadgeVariant.cs new file mode 100644 index 0000000000..265f9d88f4 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Enums/BitFullCalendarBadgeVariant.cs @@ -0,0 +1,8 @@ +namespace Bit.BlazorUI; + +public enum BitFullCalendarBadgeVariant +{ + Colored, + Dot +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Enums/BitFullCalendarMode.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Enums/BitFullCalendarMode.cs new file mode 100644 index 0000000000..631bde2229 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Enums/BitFullCalendarMode.cs @@ -0,0 +1,20 @@ +namespace Bit.BlazorUI; + +/// +/// Top-level layout mode for the calendar surface. +/// +public enum BitFullCalendarMode +{ + /// + /// Default mode: day, week, month, year, and agenda views with events placed on a date grid. + /// + Event, + + /// + /// Resource-centric layout: resources occupy the vertical axis and time the horizontal axis. + /// Available views are day (1 day, 24 hour columns), week (7 days, 168 hour columns), and + /// month (one column per day in the month). Requires the Resources parameter on the + /// calendar component. + /// + Timeline +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Enums/BitFullCalendarView.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Enums/BitFullCalendarView.cs new file mode 100644 index 0000000000..02a7f8f49f --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Enums/BitFullCalendarView.cs @@ -0,0 +1,11 @@ +namespace Bit.BlazorUI; + +public enum BitFullCalendarView +{ + Day, + Week, + Month, + Year, + Agenda +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcCalendarHeader.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcCalendarHeader.razor new file mode 100644 index 0000000000..210e39599a --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcCalendarHeader.razor @@ -0,0 +1,63 @@ +@namespace Bit.BlazorUI +@implements IDisposable + +
+
+ + +
+ +
+ + +
+ +
+ @if (!HideFilters) + { + + } + + @if (!HideSettings) + { + + } +
+
+ +@if (_showAddDialog) +{ + +} + +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarOptions Options { get; set; } = default!; + [CascadingParameter(Name = "HideFilters")] public bool HideFilters { get; set; } + [CascadingParameter(Name = "HideSettings")] public bool HideSettings { get; set; } + [CascadingParameter(Name = "OnAddClick")] public EventCallback OnAddClick { get; set; } + + private bool _showAddDialog; + + private async Task OnAddEventClick() + { + if (OnAddClick.HasDelegate) + { + var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot( + State.SelectedDate, + DateTime.Now.Hour); + await OnAddClick.InvokeAsync(draft); + } + else + _showAddDialog = true; + } + + protected override void OnInitialized() => State.OnStateChanged += Refresh; + private void Refresh() => InvokeAsync(StateHasChanged); + public void Dispose() => State.OnStateChanged -= Refresh; +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcDateNavigator.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcDateNavigator.razor new file mode 100644 index 0000000000..c64565294b --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcDateNavigator.razor @@ -0,0 +1,19 @@ +@namespace Bit.BlazorUI + +
+ + + @BitFullCalendarHelpers.RangeText(State.View, State.SelectedDate, State.Culture) + + +
+ +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcFilterEvents.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcFilterEvents.razor new file mode 100644 index 0000000000..25a260539c --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcFilterEvents.razor @@ -0,0 +1,38 @@ +@namespace Bit.BlazorUI + +
+ + + +
+ +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + + private string SelectedColorValue => State.SelectedColors.Count == 1 + ? State.SelectedColors[0] + : string.Empty; + + private void HandleColorChange(ChangeEventArgs e) + { + var value = e.Value?.ToString(); + State.SetColorFilter(string.IsNullOrWhiteSpace(value) ? null : value); + } + + private void HandleAttendeeChange(ChangeEventArgs e) => + State.SetAttendeeFilter(e.Value?.ToString()); +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcModeTabs.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcModeTabs.razor new file mode 100644 index 0000000000..a8661404b7 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcModeTabs.razor @@ -0,0 +1,30 @@ +@namespace Bit.BlazorUI + +@* + Top-level mode switch (Event ⇄ Timeline). The Timeline tab is hidden when no resources are + supplied because the timeline mode is meaningless without resources. +*@ + +@if (State.Resources.Count > 0) +{ +
+ @foreach (var mode in _modes) + { + + } +
+} + +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + + private static readonly BitFullCalendarMode[] _modes = + [ + BitFullCalendarMode.Event, + BitFullCalendarMode.Timeline + ]; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcSettings.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcSettings.razor new file mode 100644 index 0000000000..84a449d040 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcSettings.razor @@ -0,0 +1,70 @@ +@namespace Bit.BlazorUI + +
+ + @if (_open) + { +
+
@Texts.CalendarSettingsLabel
+ +
+ @Texts.DotBadgeLabel + +
+ +
+ @Texts.TwentyFourHourFormatLabel + +
+ +
+ +
+ @Texts.DayStartsAtLabel +
+ + @Texts.HourSuffix +
+
+ +
+
@Texts.AgendaGroupByLabel
+ +
+ @Texts.AgendaGroupByDate +
+
+ @Texts.AgendaGroupByColor +
+
+ } +
+ +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + private bool _open; + + private void OnStartHourChange(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out int val)) + State.SetStartOfDayHour(val); + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcTodayButton.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcTodayButton.razor new file mode 100644 index 0000000000..debdd5add3 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcTodayButton.razor @@ -0,0 +1,9 @@ +@namespace Bit.BlazorUI + + + +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcViewTabs.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcViewTabs.razor new file mode 100644 index 0000000000..51c1006f15 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcViewTabs.razor @@ -0,0 +1,32 @@ +@namespace Bit.BlazorUI + +
+ @foreach (var view in _views) + { + if (State.Mode == BitFullCalendarMode.Timeline && !_timelineViews.Contains(view)) + continue; + + + } +
+ +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + + private static readonly BitFullCalendarView[] _views = [ + BitFullCalendarView.Day, BitFullCalendarView.Week, BitFullCalendarView.Month, + BitFullCalendarView.Year, BitFullCalendarView.Agenda + ]; + + private static readonly HashSet _timelineViews = + [ + BitFullCalendarView.Day, + BitFullCalendarView.Week, + BitFullCalendarView.Month + ]; +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarAttendee.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarAttendee.cs new file mode 100644 index 0000000000..eab73f85f2 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarAttendee.cs @@ -0,0 +1,21 @@ +namespace Bit.BlazorUI; + +public class BitFullCalendarAttendee +{ + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string? Id { get; set; } + + public string FullName => $"{FirstName} {LastName}".Trim(); + + public string Initials + { + get + { + var first = FirstName.Length > 0 ? char.ToUpperInvariant(FirstName[0]).ToString() : ""; + var last = LastName.Length > 0 ? char.ToUpperInvariant(LastName[0]).ToString() : ""; + return first + last; + } + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarCell.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarCell.cs new file mode 100644 index 0000000000..b2360377d5 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarCell.cs @@ -0,0 +1,9 @@ +namespace Bit.BlazorUI; + +public class BitFullCalendarCell +{ + public int Day { get; set; } + public bool CurrentMonth { get; set; } + public DateTime Date { get; set; } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarChangeEventArgs.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarChangeEventArgs.cs new file mode 100644 index 0000000000..544e60deb0 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarChangeEventArgs.cs @@ -0,0 +1,51 @@ +namespace Bit.BlazorUI; + +/// +/// Identifies the kind of change applied to a calendar event. +/// +public enum BitFullCalendarChangeKind +{ + Add, + Edit, + Delete +} + +/// +/// Identifies where a calendar event change originated from in the UI. +/// +public enum BitFullCalendarChangeSource +{ + Dialog, + Drag, + Resize, + Delete +} + +/// +/// Provides details about a user-applied calendar event change. +/// +public sealed class BitFullCalendarChangeEventArgs +{ + /// + /// The current event snapshot after the change for Add/Edit, + /// or the removed event snapshot for Delete. + /// + public required BitFullCalendarEvent Event { get; init; } + + /// + /// The change type that occurred. + /// + public required BitFullCalendarChangeKind Kind { get; init; } + + /// + /// The event snapshot before the change for Edit/Delete. + /// Null for Add. + /// + public BitFullCalendarEvent? OldEvent { get; init; } + + /// + /// The UI source that triggered this change. + /// + public BitFullCalendarChangeSource Source { get; init; } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarColorOption.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarColorOption.cs new file mode 100644 index 0000000000..31f28ee219 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarColorOption.cs @@ -0,0 +1,44 @@ +namespace Bit.BlazorUI; + +/// +/// Describes one selectable event color in the calendar UI (picker, filters, agenda headers, +/// event badges, bullets, swatches). The list and order are controlled by the calendar component's +/// EventColorOptions parameter; events reference a color through its . +/// +public sealed class BitFullCalendarColorOption +{ + /// + /// Stable identifier of the color (matched against ). + /// Treated case-insensitively. Use a short, slug-style value such as "blue" or "skyblue". + /// + public string Id { get; set; } = string.Empty; + + /// + /// Display label shown in pickers, filters, agenda headers, and event details. This is the full + /// human-readable color name and is used as-is (no localization/transformation is applied). + /// + public string Title { get; set; } = string.Empty; + + /// + /// CSS color value used for swatches, bullets, badge accents, and chip surfaces — any value + /// accepted in CSS such as a hex ("#3b82f6"), rgb(), hsl(), or named color + /// ("skyblue"). The calendar derives badge background, border, and text contrast tints + /// from this single value at runtime. + /// + public string Value { get; set; } = string.Empty; + + /// + /// Built-in palette used when EventColorOptions is null or empty. The IDs + /// ("blue", "green", ...) match the values previously emitted by + /// BitFullCalendarEventColor, so events created against the defaults need no migration. + /// + public static IReadOnlyList Defaults { get; } = + [ + new() { Id = "blue", Title = "Blue", Value = "#3b82f6" }, + new() { Id = "green", Title = "Green", Value = "#22c55e" }, + new() { Id = "red", Title = "Red", Value = "#ef4444" }, + new() { Id = "yellow", Title = "Yellow", Value = "#eab308" }, + new() { Id = "purple", Title = "Purple", Value = "#a855f7" }, + new() { Id = "orange", Title = "Orange", Value = "#f97316" }, + ]; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarColorScheme.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarColorScheme.cs new file mode 100644 index 0000000000..50670803db --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarColorScheme.cs @@ -0,0 +1,103 @@ +namespace Bit.BlazorUI; + +/// +/// Resolved color list and lookup helpers. Built from the calendar's EventColorOptions +/// parameter (or the built-in palette when +/// none was supplied). Events reference a color through . +/// +public sealed class BitFullCalendarColorScheme +{ + /// Id used when an event's is null/empty. + public const string FallbackColorId = "blue"; + + /// Inline style emitted on color-bearing elements (bullets, swatches, chips, blocks). + public const string ColorVariableName = "--bfc-evt-color"; + + private readonly Dictionary _byId; + + public BitFullCalendarColorScheme(IReadOnlyList? options) + { + var list = options is { Count: > 0 } ? options : BitFullCalendarColorOption.Defaults; + Options = list; + _byId = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var o in list) + { + var id = o.Id?.Trim(); + if (!string.IsNullOrEmpty(id) && !_byId.ContainsKey(id)) + _byId[id] = o; + } + } + + /// Configured colors in display order. + public IReadOnlyList Options { get; } + + /// Looks up a color option by id (case-insensitive). Returns null when unknown. + public BitFullCalendarColorOption? Find(string? colorId) + { + if (string.IsNullOrWhiteSpace(colorId)) + return null; + return _byId.TryGetValue(colorId.Trim(), out var o) ? o : null; + } + + /// Display label for dropdowns, filters, agenda headers, and event details. + public string GetLabel(string? colorId) + { + var opt = Find(colorId); + if (opt is not null && !string.IsNullOrWhiteSpace(opt.Title)) + return opt.Title; + return colorId ?? string.Empty; + } + + /// CSS color value for the supplied id (falls back to the first configured color). + public string GetCssValue(string? colorId) + { + var opt = Find(colorId); + if (opt is not null && !string.IsNullOrWhiteSpace(opt.Value)) + return opt.Value; + var first = Options.Count > 0 ? Options[0] : null; + return first?.Value ?? "#3b82f6"; + } + + /// + /// Inline style string that publishes the resolved color value as the + /// CSS custom property. Combine with the matching CSS classes + /// (e.g. bfc-color, bfc-bg, bfc-bullet) to render the chip surface. + /// + public string GetColorStyle(string? colorId) => + $"{ColorVariableName}:{GetCssValue(colorId)};"; + + /// + /// Options shown in the add/edit dialog. If the event references an id that is not in + /// (for example a color removed at runtime) the missing entry is + /// appended so the value remains selectable. + /// + public IReadOnlyList GetEditorOptions(string? editingColorId) + { + if (string.IsNullOrWhiteSpace(editingColorId) || _byId.ContainsKey(editingColorId.Trim())) + return Options; + + var extra = new List(Options.Count + 1); + extra.AddRange(Options); + extra.Add(new BitFullCalendarColorOption + { + Id = editingColorId.Trim(), + Title = editingColorId.Trim(), + Value = GetCssValue(editingColorId) + }); + return extra; + } + + /// Sort key for agenda grouping — configured order first, then unknown ids by name. + public int GetSortOrder(string? colorId) + { + if (string.IsNullOrWhiteSpace(colorId)) + return int.MaxValue; + var trimmed = colorId.Trim(); + for (var i = 0; i < Options.Count; i++) + { + if (string.Equals(Options[i].Id, trimmed, StringComparison.OrdinalIgnoreCase)) + return i; + } + return 1000 + StringComparer.OrdinalIgnoreCase.GetHashCode(trimmed); + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarDateChangeEventArgs.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarDateChangeEventArgs.cs new file mode 100644 index 0000000000..93376d0154 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarDateChangeEventArgs.cs @@ -0,0 +1,17 @@ +namespace Bit.BlazorUI; + +/// +/// Provides details about a date range change in the calendar, +/// fired when the user navigates (prev/next/today) or switches views. +/// +public sealed class BitFullCalendarDateChangeEventArgs +{ + /// Start of the visible date range (inclusive). + public required DateTime Start { get; init; } + + /// End of the visible date range (inclusive). + public required DateTime End { get; init; } + + /// The active calendar view when the change occurred. + public required BitFullCalendarView View { get; init; } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarEvent.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarEvent.cs new file mode 100644 index 0000000000..8a558a621d --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarEvent.cs @@ -0,0 +1,31 @@ +namespace Bit.BlazorUI; + +public class BitFullCalendarEvent +{ + public string Id { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + /// + /// Identifier of the color (matches a from the + /// calendar's configured palette). Defaults to + /// so that out-of-the-box rendering keeps working with the built-in palette. + /// + public string Color { get; set; } = BitFullCalendarColorScheme.FallbackColorId; + public List Attendees { get; set; } = []; + + /// + /// Optional resource identifier linking this event to a + /// (for example a meeting room name or a machine id). Used by the resource timeline view to + /// place the event on the matching resource row. null or empty means the event is unassigned. + /// + public string? Resource { get; set; } + + public bool IsSingleDay => StartDate.Date == EndDate.Date; + public bool IsMultiDay => !IsSingleDay; + public TimeSpan Duration => EndDate - StartDate; + + public object? Data { get; set; } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarOptions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarOptions.cs new file mode 100644 index 0000000000..d821e3205c --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarOptions.cs @@ -0,0 +1,22 @@ +namespace Bit.BlazorUI; + +/// +/// Configuration options for the component. +/// These values are applied as initial defaults when the component mounts, +/// or whenever a new instance is assigned. +/// +public class BitFullCalendarOptions +{ + /// Uses 24-hour time format instead of 12-hour (AM/PM). + public bool Use24HourFormat { get; set; } = true; + + /// Badge display style in the month view. + public BitFullCalendarBadgeVariant BadgeVariant { get; set; } = BitFullCalendarBadgeVariant.Colored; + + /// Hour (0–16) at which the day/week time grid begins. + public int StartOfDayHour { get; set; } = 8; + + /// How events are grouped in the agenda view. + public BitFullCalendarAgendaGroupBy AgendaModeGroupBy { get; set; } = BitFullCalendarAgendaGroupBy.Date; + +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarResource.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarResource.cs new file mode 100644 index 0000000000..f5b80893c1 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarResource.cs @@ -0,0 +1,30 @@ +namespace Bit.BlazorUI; + +/// +/// A schedulable resource shown as a row in the resource timeline view (for example, +/// a meeting room, a person, a piece of equipment). +/// Events are linked to a resource through +/// matching . +/// +public sealed class BitFullCalendarResource +{ + /// + /// Stable identifier matched against . + /// + public string Id { get; set; } = string.Empty; + + /// + /// Display name for the resource (for example "Bay Wing", "Alice Johnson", "Meeting Room 3B"). + /// + public string Title { get; set; } = string.Empty; + + /// + /// Optional subtitle shown below the resource title (for example building, department). + /// + public string? Subtitle { get; set; } + + /// + /// Optional consumer-defined payload available to templates and click handlers. + /// + public object? Data { get; set; } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarTexts.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarTexts.cs new file mode 100644 index 0000000000..d9ba7b8226 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarTexts.cs @@ -0,0 +1,109 @@ +namespace Bit.BlazorUI; + +public class BitFullCalendarTexts +{ + public string ViewDay { get; set; } = "Day"; + public string ViewWeek { get; set; } = "Week"; + public string ViewMonth { get; set; } = "Month"; + public string ViewYear { get; set; } = "Year"; + public string ViewAgenda { get; set; } = "Agenda"; + + public string ModeEvent { get; set; } = "Events"; + public string ModeTimeline { get; set; } = "Timeline"; + + public string BitFcTodayButton { get; set; } = "Today"; + public string AddEventButton { get; set; } = "Add Event"; + public string AddEventHoverHint { get; set; } = "Add event"; + public string PreviousButtonTitle { get; set; } = "Previous"; + public string NextButtonTitle { get; set; } = "Next"; + public string SettingsButtonTitle { get; set; } = "Settings"; + + public string FilterByColorAriaLabel { get; set; } = "Filter events by color"; + public string FilterByPersonAriaLabel { get; set; } = "Filter events by person in current view"; + public string AllColorsOption { get; set; } = "All colors"; + public string AllPeopleOption { get; set; } = "All people"; + public string UnnamedAttendee { get; set; } = "(Unnamed)"; + + public string CalendarSettingsLabel { get; set; } = "Calendar settings"; + public string DotBadgeLabel { get; set; } = "Dot badge"; + public string TwentyFourHourFormatLabel { get; set; } = "24-hour format"; + public string DayStartsAtLabel { get; set; } = "Day starts at"; + public string HourSuffix { get; set; } = "h"; + public string AgendaGroupByLabel { get; set; } = "Agenda group by"; + public string AgendaGroupByDate { get; set; } = "Date"; + public string AgendaGroupByColor { get; set; } = "Color"; + + public string WeekMobileWarning { get; set; } = "Weekly view is not recommended on smaller devices. Please switch to a desktop device or use the daily view instead."; + public string HappeningNowTitle { get; set; } = "Happening now"; + public string NoAppointmentsNow { get; set; } = "No appointments at the moment"; + + public string SearchEventsPlaceholder { get; set; } = "Search events..."; + public string NoEventsFound { get; set; } = "No events found."; + + public string EventListTitlePrefix { get; set; } = "Events on"; + public string EventListCountSuffix { get; set; } = "event(s)"; + public string MoreEventsSuffix { get; set; } = "more"; + + public string AddEventDialogTitle { get; set; } = "Add New Event"; + public string EditEventDialogTitle { get; set; } = "Edit Event"; + public string AddEventDialogSubtitle { get; set; } = "Create a new event for your calendar."; + public string EditEventDialogSubtitle { get; set; } = "Modify your existing event."; + + public string CloseAriaLabel { get; set; } = "Close"; + public string CloseButton { get; set; } = "Close"; + public string CancelButton { get; set; } = "Cancel"; + public string EditButton { get; set; } = "Edit"; + public string DeleteButton { get; set; } = "Delete"; + public string CreateEventButton { get; set; } = "Create Event"; + public string SaveChangesButton { get; set; } = "Save Changes"; + + public string TitleLabel { get; set; } = "Title"; + public string EventTitlePlaceholder { get; set; } = "Event title"; + public string StartDateTimeLabel { get; set; } = "Start Date & Time"; + public string EndDateTimeLabel { get; set; } = "End Date & Time"; + public string ColorLabel { get; set; } = "Color"; + public string EventColorAriaLabel { get; set; } = "Event color"; + public string DescriptionLabel { get; set; } = "Description"; + public string EventDescriptionPlaceholder { get; set; } = "Event description"; + public string AttendeesLabel { get; set; } = "Attendees"; + public string NoAttendeesText { get; set; } = "No attendees"; + public string FirstNamePlaceholder { get; set; } = "First name"; + public string LastNamePlaceholder { get; set; } = "Last name"; + public string IdOptionalPlaceholder { get; set; } = "ID (optional)"; + public string AddButton { get; set; } = "Add"; + + public string StartDateLabel { get; set; } = "Start Date"; + public string EndDateLabel { get; set; } = "End Date"; + public string AtWord { get; set; } = "at"; + + public string ValidationTitleRequired { get; set; } = "Title is required"; + public string ValidationDescriptionRequired { get; set; } = "Description is required"; + public string ValidationEndAfterStart { get; set; } = "End date must be after start date"; + public string ValidationAttendeeNameRequired { get; set; } = "First name or last name is required"; + + public string ResizePreviewAriaLabel { get; set; } = "New time range"; + + public string ResourceLabel { get; set; } = "Resource"; + public string ResourceColumnHeader { get; set; } = "Resource"; + public string NoResourceLabel { get; set; } = "Unassigned"; + public string NoResourceOption { get; set; } = "(none)"; + public string NoResourcesMessage { get; set; } = "No resources to display."; + + public string GetViewLabel(BitFullCalendarView view) => view switch + { + BitFullCalendarView.Day => ViewDay, + BitFullCalendarView.Week => ViewWeek, + BitFullCalendarView.Month => ViewMonth, + BitFullCalendarView.Year => ViewYear, + BitFullCalendarView.Agenda => ViewAgenda, + _ => view.ToString() + }; + + public string GetModeLabel(BitFullCalendarMode mode) => mode switch + { + BitFullCalendarMode.Event => ModeEvent, + BitFullCalendarMode.Timeline => ModeTimeline, + _ => mode.ToString() + }; +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcAgendaScrollInterop.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcAgendaScrollInterop.cs new file mode 100644 index 0000000000..36a35e8ffa --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcAgendaScrollInterop.cs @@ -0,0 +1,39 @@ +using Microsoft.JSInterop; + +namespace Bit.BlazorUI; + +internal static class BitFcAgendaScrollInterop +{ + public static async ValueTask TryScrollToDateAsync( + IJSRuntime js, + string scrollContainerId, + DateTime date, + CancellationToken cancellationToken = default) + { + try + { + await js.InvokeVoidAsync( + "BitBlazorUI.FullCalendar.scrollAgendaToDate", + cancellationToken, + scrollContainerId, + date.ToString("yyyy-MM-dd")); + return true; + } + catch (JSDisconnectedException) + { + return false; + } + catch (OperationCanceledException) + { + return false; + } + catch (JSException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcTimeGridScrollInterop.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcTimeGridScrollInterop.cs new file mode 100644 index 0000000000..fe4ec990ab --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcTimeGridScrollInterop.cs @@ -0,0 +1,41 @@ +using Microsoft.JSInterop; + +namespace Bit.BlazorUI; + +internal static class BitFcTimeGridScrollInterop +{ + public static async ValueTask TryScrollToStartOfDayAsync( + IJSRuntime js, + string elementId, + int startOfDayHour, + CancellationToken cancellationToken = default) + { + try + { + await js.InvokeVoidAsync( + "BitBlazorUI.FullCalendar.scrollToHour", + cancellationToken, + elementId, + startOfDayHour, + BitFullCalendarHelpers.HourHeightPx); + return true; + } + catch (JSDisconnectedException) + { + return false; + } + catch (OperationCanceledException) + { + return false; + } + catch (JSException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcTimelineScrollInterop.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcTimelineScrollInterop.cs new file mode 100644 index 0000000000..b8944d7762 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcTimelineScrollInterop.cs @@ -0,0 +1,36 @@ +using Microsoft.JSInterop; + +namespace Bit.BlazorUI; + +internal static class BitFcTimelineScrollInterop +{ + public static async ValueTask TryScrollToTargetAsync( + IJSRuntime js, + string scrollContainerId, + CancellationToken cancellationToken = default) + { + try + { + return await js.InvokeAsync( + "BitBlazorUI.FullCalendar.scrollTimelineToTarget", + cancellationToken, + scrollContainerId); + } + catch (JSDisconnectedException) + { + return false; + } + catch (OperationCanceledException) + { + return false; + } + catch (JSException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarChangeNotifier.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarChangeNotifier.cs new file mode 100644 index 0000000000..02f74741b4 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarChangeNotifier.cs @@ -0,0 +1,92 @@ +using Bit.BlazorUI; + +namespace Bit.BlazorUI; + +/// +/// Dispatches calendar event change notifications to the component consumer. +/// Also provides wrappers for mutation paths that need pre/post snapshots. +/// +public sealed class BitFullCalendarChangeNotifier +{ + private readonly BitFullCalendarState _state; + private readonly Func _dispatch; + + public BitFullCalendarChangeNotifier(BitFullCalendarState state, Func dispatch) + { + _state = state; + _dispatch = dispatch; + } + + /// + /// Dispatches a change payload to the component's OnChange callback. + /// + public Task NotifyAsync(BitFullCalendarChangeEventArgs args) => _dispatch(args); + + /// + /// Applies drop logic through and emits + /// an Edit change when the event date-time has actually changed. + /// + public Task HandleDropAsync(DateTime targetDate, int? hour = null, int? minute = null) + => HandleDropCoreAsync(targetDate, hour, minute, resourceId: null, applyResource: false); + + /// + /// Drops the dragged event on the supplied date/time and (optionally) reassigns its resource, + /// emitting an Edit change when anything actually changed. + /// + public Task HandleResourceDropAsync(DateTime targetDate, int? hour, int? minute, string? resourceId) + => HandleDropCoreAsync(targetDate, hour, minute, resourceId, applyResource: true); + + private Task HandleDropCoreAsync(DateTime targetDate, int? hour, int? minute, string? resourceId, bool applyResource) + { + var dragged = _state.DraggedEvent; + if (dragged is null) + return Task.CompletedTask; + + var oldSnapshot = CloneEvent(dragged); + var eventId = dragged.Id; + + _state.HandleDrop(targetDate, hour, minute, resourceId, applyResource); + + var after = _state.AllEvents.FirstOrDefault(e => e.Id == eventId); + if (after is null) + return Task.CompletedTask; + + var sameTime = after.StartDate == oldSnapshot.StartDate && after.EndDate == oldSnapshot.EndDate; + var sameResource = string.Equals(after.Resource ?? "", oldSnapshot.Resource ?? "", StringComparison.Ordinal); + if (sameTime && sameResource) + return Task.CompletedTask; + + return NotifyAsync(new BitFullCalendarChangeEventArgs + { + Event = CloneEvent(after), + OldEvent = oldSnapshot, + Kind = BitFullCalendarChangeKind.Edit, + Source = BitFullCalendarChangeSource.Drag + }); + } + + /// + /// Creates a deep snapshot of a calendar event payload suitable for change args. + /// + public static BitFullCalendarEvent CloneEvent(BitFullCalendarEvent source) => + new() + { + Id = source.Id, + Title = source.Title, + Description = source.Description, + StartDate = source.StartDate, + EndDate = source.EndDate, + Color = source.Color, + Resource = source.Resource, + Data = source.Data, + Attendees = source.Attendees + .Select(a => new BitFullCalendarAttendee + { + FirstName = a.FirstName, + LastName = a.LastName, + Id = a.Id + }) + .ToList() + }; +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarHelpers.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarHelpers.cs new file mode 100644 index 0000000000..b30524aeb6 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarHelpers.cs @@ -0,0 +1,736 @@ +using System.Globalization; + +namespace Bit.BlazorUI; + +public static class BitFullCalendarHelpers +{ + public const int HourHeightPx = 96; + /// Width of a single hour column on the timeline-mode day/week views. + public const int TimelineHourWidthPx = 96; + /// Width of a single day column on the timeline-mode month view. + public const int TimelineDayWidthPx = 56; + private const string FormatString = "MMM d, yyyy"; + + // -- Culture-aware: Range text ------------------------------ + + public static string RangeText(BitFullCalendarView view, DateTime date, CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var cal = culture.Calendar; + var dtf = culture.DateTimeFormat; + + switch (view) + { + case BitFullCalendarView.Month: + case BitFullCalendarView.Agenda: + { + int y = cal.GetYear(date); + int m = cal.GetMonth(date); + string monthName = dtf.GetMonthName(m); + return $"{monthName} {y}"; + } + case BitFullCalendarView.Week: + { + var start = StartOfWeek(date, culture); + var end = start.AddDays(6); + return $"{FormatCultureDate(start, culture)} - {FormatCultureDate(end, culture)}"; + } + case BitFullCalendarView.Day: + return FormatCultureDate(date, culture); + case BitFullCalendarView.Year: + { + int y = cal.GetYear(date); + return y.ToString(culture); + } + default: + return "Error"; + } + } + + /// Formats a date as "Mon d, Year" using the supplied culture's calendar. + public static string FormatCultureDate(DateTime date, CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var cal = culture.Calendar; + var dtf = culture.DateTimeFormat; + int y = cal.GetYear(date); + int m = cal.GetMonth(date); + int d = cal.GetDayOfMonth(date); + string abbr = dtf.GetAbbreviatedMonthName(m); + return $"{abbr} {d}, {y}"; + } + + // -- Culture-aware: Navigation ------------------------------ + + public static DateTime NavigateDate(DateTime date, BitFullCalendarView view, bool forward, CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var cal = culture.Calendar; + int delta = forward ? 1 : -1; + return view switch + { + BitFullCalendarView.Month => cal.AddMonths(date, delta), + BitFullCalendarView.Week => date.AddDays(forward ? 7 : -7), + BitFullCalendarView.Day => date.AddDays(delta), + BitFullCalendarView.Year => cal.AddYears(date, delta), + BitFullCalendarView.Agenda => cal.AddMonths(date, delta), + _ => date + }; + } + + // -- Culture-aware: Week helpers ------------------------------ + + public static DateTime StartOfWeek(DateTime date, CultureInfo? culture = null) + { + var startDay = culture?.DateTimeFormat.FirstDayOfWeek ?? DayOfWeek.Sunday; + return StartOfWeek(date, startDay); + } + + public static DateTime StartOfWeek(DateTime date, DayOfWeek startDay) + { + int diff = (7 + (date.DayOfWeek - startDay)) % 7; + return date.Date.AddDays(-diff); + } + + public static DateTime[] GetWeekDates(DateTime date, CultureInfo? culture = null) + { + var start = StartOfWeek(date, culture); + return Enumerable.Range(0, 7).Select(i => start.AddDays(i)).ToArray(); + } + + // -- Culture-aware: Weekday header names ------------------------------ + + /// + /// Returns 7 shortest day-name strings (1 char) starting from + /// culture.DateTimeFormat.FirstDayOfWeek. + /// + public static string[] GetWeekDayHeaders(CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var dtf = culture.DateTimeFormat; + var first = (int)dtf.FirstDayOfWeek; + return Enumerable.Range(0, 7) + .Select(i => dtf.GetShortestDayName((DayOfWeek)((first + i) % 7))) + .ToArray(); + } + + /// + /// Returns 7 abbreviated day-name strings (2-3 chars) starting from + /// culture.DateTimeFormat.FirstDayOfWeek. + /// + public static string[] GetAbbreviatedWeekDayHeaders(CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var dtf = culture.DateTimeFormat; + var first = (int)dtf.FirstDayOfWeek; + return Enumerable.Range(0, 7) + .Select(i => dtf.GetAbbreviatedDayName((DayOfWeek)((first + i) % 7))) + .ToArray(); + } + + // -- Culture-aware: Calendar grid cells ------------------------------ + + public static List GetCalendarCells(DateTime selectedDate, CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var cal = culture.Calendar; + var dtf = culture.DateTimeFormat; + + int culturalYear = cal.GetYear(selectedDate); + int culturalMonth = cal.GetMonth(selectedDate); + + // First day of this cultural month as a Gregorian DateTime + DateTime firstDay = cal.ToDateTime(culturalYear, culturalMonth, 1, 0, 0, 0, 0); + int daysInMonth = cal.GetDaysInMonth(culturalYear, culturalMonth); + + // Leading blank cells (days from prev cultural month) + int firstDow = (int)cal.GetDayOfWeek(firstDay); + int culturalFirst = (int)dtf.FirstDayOfWeek; + int leadingDays = (firstDow - culturalFirst + 7) % 7; + + // Previous cultural month + int prevCulturalMonth = culturalMonth == 1 + ? cal.GetMonthsInYear(culturalYear - 1) + : culturalMonth - 1; + int prevCulturalYear = culturalMonth == 1 ? culturalYear - 1 : culturalYear; + int daysInPrevMonth = cal.GetDaysInMonth(prevCulturalYear, prevCulturalMonth); + + var cells = new List(); + + for (int i = 0; i < leadingDays; i++) + { + int d = daysInPrevMonth - leadingDays + i + 1; + DateTime date = cal.ToDateTime(prevCulturalYear, prevCulturalMonth, d, 0, 0, 0, 0); + cells.Add(new BitFullCalendarCell { Day = d, CurrentMonth = false, Date = date }); + } + + for (int i = 1; i <= daysInMonth; i++) + { + DateTime date = cal.ToDateTime(culturalYear, culturalMonth, i, 0, 0, 0, 0); + cells.Add(new BitFullCalendarCell { Day = i, CurrentMonth = true, Date = date }); + } + + int totalDays = leadingDays + daysInMonth; + int trailing = (7 - (totalDays % 7)) % 7; + int nextCulturalMonth = culturalMonth == cal.GetMonthsInYear(culturalYear) + ? 1 + : culturalMonth + 1; + int nextCulturalYear = culturalMonth == cal.GetMonthsInYear(culturalYear) + ? culturalYear + 1 + : culturalYear; + + for (int i = 1; i <= trailing; i++) + { + DateTime date = cal.ToDateTime(nextCulturalYear, nextCulturalMonth, i, 0, 0, 0, 0); + cells.Add(new BitFullCalendarCell { Day = i, CurrentMonth = false, Date = date }); + } + + return cells; + } + + // -- Culture-aware: Day-of-month display ------------------------------ + + public static int GetCulturalDayOfMonth(DateTime date, CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + return culture.Calendar.GetDayOfMonth(date); + } + + // -- Culture-aware: Events for year ------------------------------ + + public static List GetEventsForYear(List events, DateTime date, CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var cal = culture.Calendar; + int culturalYear = cal.GetYear(date); + DateTime yearStart = cal.ToDateTime(culturalYear, 1, 1, 0, 0, 0, 0); + int monthsInYear = cal.GetMonthsInYear(culturalYear); + int lastDayOfYear = cal.GetDaysInMonth(culturalYear, monthsInYear); + DateTime yearEnd = cal.ToDateTime(culturalYear, monthsInYear, lastDayOfYear, 23, 59, 59, 0); + return events.Where(ev => ev.StartDate.Date <= yearEnd && ev.EndDate.Date >= yearStart).ToList(); + } + + public static string FormatTime(DateTime date, bool use24Hour) + { + return use24Hour + ? date.ToString("HH:mm", CultureInfo.InvariantCulture) + : date.ToString("h:mm tt", CultureInfo.InvariantCulture); + } + + /// + /// Builds a human-friendly tooltip string for an event. Includes the title, the time range, + /// and (when present and not redundant with the title) the description. Used by event cards + /// where layout space may hide most of the visual content. + /// + public static string BuildEventTooltip(BitFullCalendarEvent ev, bool use24Hour) + { + if (ev is null) + return string.Empty; + + var title = string.IsNullOrWhiteSpace(ev.Title) ? string.Empty : ev.Title.Trim(); + var time = $"{FormatTime(ev.StartDate, use24Hour)} - {FormatTime(ev.EndDate, use24Hour)}"; + + var lines = new List(3); + if (!string.IsNullOrEmpty(title)) + lines.Add(title); + lines.Add(time); + + if (!string.IsNullOrWhiteSpace(ev.Description)) + { + var description = ev.Description.Trim(); + if (!string.Equals(description, title, StringComparison.Ordinal)) + lines.Add(description); + } + + return string.Join('\n', lines); + } + + public static string FormatHourLabel(int hour, bool use24Hour) + { + var dt = DateTime.Today.AddHours(hour); + return use24Hour + ? dt.ToString("HH:00", CultureInfo.InvariantCulture) + : dt.ToString("h tt", CultureInfo.InvariantCulture); + } + + /// + /// Computes horizontal pixel position and width for an event placed on a resource timeline row. + /// The event range is clipped to the visible day so events that span past midnight stay + /// inside the row. Returns (LeftPx, WidthPx) or null when the event has no overlap with the day. + /// + public static (double LeftPx, double WidthPx)? GetTimelineBlockPosition( + BitFullCalendarEvent ev, DateTime day, int hourWidthPx = TimelineHourWidthPx) + { + var dayStart = day.Date; + var dayEnd = dayStart.AddDays(1); + + var clippedStart = ev.StartDate < dayStart ? dayStart : ev.StartDate; + var clippedEnd = ev.EndDate > dayEnd ? dayEnd : ev.EndDate; + if (clippedEnd <= clippedStart) + return null; + + var pxPerMinute = hourWidthPx / 60.0; + var leftMinutes = (clippedStart - dayStart).TotalMinutes; + var widthMinutes = (clippedEnd - clippedStart).TotalMinutes; + + return (leftMinutes * pxPerMinute, widthMinutes * pxPerMinute); + } + + /// + /// Groups events for a single day by their id. + /// Within each resource, events are arranged into non-overlapping lanes so overlapping events + /// stack vertically inside the same row (similar to for day/week). + /// Events with no resource id (or an id not in ) are placed + /// under . + /// + public static Dictionary>> GroupEventsByResourceForDay( + List events, + DateTime day, + IEnumerable resourceIds, + string unassignedKey) + { + var dayStart = day.Date; + var dayEnd = dayStart.AddDays(1); + + var keyed = new Dictionary>(StringComparer.Ordinal); + foreach (var id in resourceIds) + { + if (!keyed.ContainsKey(id)) + keyed[id] = []; + } + keyed[unassignedKey] = []; + + var validIds = new HashSet(keyed.Keys, StringComparer.Ordinal); + + foreach (var ev in events) + { + if (ev.StartDate >= dayEnd || ev.EndDate <= dayStart) + continue; + + var key = ev.Resource is { Length: > 0 } r && validIds.Contains(r) ? r : unassignedKey; + keyed[key].Add(ev); + } + + return keyed.ToDictionary( + kv => kv.Key, + kv => GroupEvents(kv.Value), + StringComparer.Ordinal); + } + + /// + /// Groups events that overlap a calendar month by their + /// id. Within each resource the events are arranged into non-overlapping lanes so multi-day events + /// stack vertically inside the resource row. Events with no resource id (or an id not in + /// ) are placed under . + /// + public static Dictionary>> GroupEventsByResourceForMonth( + List events, + DateTime monthStart, + int daysInMonth, + IEnumerable resourceIds, + string unassignedKey) + { + var monthEnd = monthStart.AddDays(daysInMonth); + + var keyed = new Dictionary>(StringComparer.Ordinal); + foreach (var id in resourceIds) + { + if (!keyed.ContainsKey(id)) + keyed[id] = []; + } + keyed[unassignedKey] = []; + + var validIds = new HashSet(keyed.Keys, StringComparer.Ordinal); + + foreach (var ev in events) + { + if (ev.StartDate >= monthEnd || ev.EndDate <= monthStart) + continue; + + var key = ev.Resource is { Length: > 0 } r && validIds.Contains(r) ? r : unassignedKey; + keyed[key].Add(ev); + } + + return keyed.ToDictionary( + kv => kv.Key, + kv => GroupEventsByDayRange(kv.Value, monthStart, monthEnd), + StringComparer.Ordinal); + } + + /// + /// Day-range variant of : events are sorted by start, then placed in the + /// first lane whose tail event ends on or before the candidate's start day. Used by the timeline + /// month view where the granularity is one column per day. + /// + private static List> GroupEventsByDayRange( + List events, DateTime rangeStart, DateTime rangeEnd) + { + var sorted = events.OrderBy(e => e.StartDate).ThenByDescending(e => e.EndDate).ToList(); + var lanes = new List>(); + + DateTime ClipStartDate(BitFullCalendarEvent e) => (e.StartDate < rangeStart ? rangeStart : e.StartDate).Date; + DateTime ClipEndDate(BitFullCalendarEvent e) + { + var end = e.EndDate > rangeEnd ? rangeEnd : e.EndDate; + // Treat 00:00 boundary as ending the previous day (exclusive end). + return end.TimeOfDay == TimeSpan.Zero ? end.Date.AddDays(-1) : end.Date; + } + + foreach (var ev in sorted) + { + var s = ClipStartDate(ev); + var placed = false; + foreach (var lane in lanes) + { + if (s > ClipEndDate(lane[^1])) + { + lane.Add(ev); + placed = true; + break; + } + } + if (!placed) + lanes.Add([ev]); + } + + return lanes; + } + + public static List> GroupEvents(List dayEvents) + { + var sorted = dayEvents.OrderBy(e => e.StartDate).ToList(); + var groups = new List>(); + + foreach (var ev in sorted) + { + bool placed = false; + foreach (var group in groups) + { + if (ev.StartDate >= group[^1].EndDate) + { + group.Add(ev); + placed = true; + break; + } + } + if (!placed) + groups.Add([ev]); + } + + return groups; + } + + public static (double TopPx, double WidthPercent, double LeftPercent) GetEventBlockStyle( + BitFullCalendarEvent ev, DateTime day, int groupIndex, int groupSize) + { + var dayStart = day.Date; + var eventStart = ev.StartDate < dayStart ? dayStart : ev.StartDate; + double startMinutes = (eventStart - dayStart).TotalMinutes; + double topPx = startMinutes / 60.0 * HourHeightPx; + double width = 100.0 / groupSize; + double left = groupIndex * width; + return (topPx, width, left); + } + + private static (int Year, int Month, int Day) MonthGridDayKey(DateTime d) + { + d = d.Date; + return (d.Year, d.Month, d.Day); + } + + public static Dictionary CalculateMonthEventPositions( + List multiDayEvents, + List singleDayEvents, + DateTime selectedDate, + CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var cal = culture.Calendar; + int y = cal.GetYear(selectedDate); + int m = cal.GetMonth(selectedDate); + DateTime monthStart = cal.ToDateTime(y, m, 1, 0, 0, 0, 0); + DateTime monthEnd = cal.AddMonths(monthStart, 1).AddDays(-1); + + var eventPositions = new Dictionary(); + var occupiedPositions = new Dictionary<(int Year, int Month, int Day), bool[]>(); + + for (var d = monthStart; d <= monthEnd; d = d.AddDays(1)) + occupiedPositions[MonthGridDayKey(d)] = new bool[3]; + + var sorted = multiDayEvents + .OrderByDescending(e => (e.EndDate - e.StartDate).TotalDays) + .ThenBy(e => e.StartDate) + .Concat(singleDayEvents.OrderBy(e => e.StartDate)) + .ToList(); + + foreach (var ev in sorted) + { + var evStart = ev.StartDate.Date; + var evEnd = ev.EndDate.Date; + var rangeStart = evStart < monthStart ? monthStart : evStart; + var rangeEnd = evEnd > monthEnd ? monthEnd : evEnd; + + var eventDays = new List(); + for (var d = rangeStart; d <= rangeEnd; d = d.AddDays(1)) + eventDays.Add(d); + + int position = -1; + for (int i = 0; i < 3; i++) + { + if (eventDays.All(d => + { + var key = MonthGridDayKey(d); + return occupiedPositions.TryGetValue(key, out var slots) && !slots[i]; + })) + { + position = i; + break; + } + } + + if (position != -1) + { + foreach (var d in eventDays) + { + var key = MonthGridDayKey(d); + if (occupiedPositions.TryGetValue(key, out var slots)) + slots[position] = true; + } + eventPositions[ev.Id] = position; + } + } + + return eventPositions; + } + + public static List<(BitFullCalendarEvent Event, int Position, bool IsMultiDay)> GetMonthCellEvents( + DateTime date, List events, Dictionary eventPositions) + { + var dayStart = date.Date; + var eventsForDate = events.Where(ev => + { + var s = ev.StartDate.Date; + var e = ev.EndDate.Date; + return (dayStart >= s && dayStart <= e) || s == dayStart || e == dayStart; + }).ToList(); + + var raw = eventsForDate + .Select(ev => ( + Event: ev, + Position: eventPositions.GetValueOrDefault(ev.Id, -1), + IsMultiDay: ev.IsMultiDay + )) + .OrderByDescending(x => x.IsMultiDay) + .ThenBy(x => x.Position < 0 ? 100 : x.Position) + .ThenBy(x => x.Event.StartDate) + .ToList(); + + return AssignMonthCellDisplayRows(raw); + } + + private static List<(BitFullCalendarEvent Event, int Position, bool IsMultiDay)> AssignMonthCellDisplayRows( + List<(BitFullCalendarEvent Event, int Position, bool IsMultiDay)> raw) + { + var occupied = new bool[3]; + var result = new List<(BitFullCalendarEvent Event, int Position, bool IsMultiDay)>(); + + foreach (var x in raw) + { + var p = x.Position; + if (p is >= 0 and < 3 && !occupied[p]) + { + occupied[p] = true; + result.Add((x.Event, p, x.IsMultiDay)); + continue; + } + + var free = -1; + for (var i = 0; i < 3; i++) + { + if (!occupied[i]) + { + free = i; + break; + } + } + + if (free >= 0) + { + occupied[free] = true; + result.Add((x.Event, free, x.IsMultiDay)); + } + else + { + result.Add((x.Event, -1, x.IsMultiDay)); + } + } + + return result + .OrderByDescending(x => x.IsMultiDay) + .ThenBy(x => x.Position < 0 ? 100 : x.Position) + .ThenBy(x => x.Event.StartDate) + .ToList(); + } + + public static List GetEventsForDay(List events, DateTime date, bool weekOnly = false) + { + var target = date.Date; + return events.Where(ev => + { + var s = ev.StartDate.Date; + var e = ev.EndDate.Date; + if (weekOnly) + return ev.IsMultiDay && s <= target && e >= target; + return s <= target && e >= target; + }).ToList(); + } + + public static List GetEventsForWeek(List events, DateTime date, CultureInfo? culture = null) + { + var weekStart = StartOfWeek(date, culture); + var weekEnd = weekStart.AddDays(6); + return events.Where(ev => + ev.StartDate.Date <= weekEnd && ev.EndDate.Date >= weekStart).ToList(); + } + + public static List GetEventsForMonth(List events, DateTime date, CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var cal = culture.Calendar; + int y = cal.GetYear(date); + int m = cal.GetMonth(date); + DateTime monthStart = cal.ToDateTime(y, m, 1, 0, 0, 0, 0); + DateTime monthEnd = cal.AddMonths(monthStart, 1).AddDays(-1); + return events.Where(ev => + ev.StartDate.Date <= monthEnd && ev.EndDate.Date >= monthStart).ToList(); + } + + /// + /// Events overlapping the date range implied by the current view and selected date + /// (used for attendee filters and similar “in this view” logic). + /// + public static List GetEventsForView( + List events, + BitFullCalendarView view, + DateTime selectedDate, + CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + return view switch + { + BitFullCalendarView.Day => GetEventsForDay(events, selectedDate), + BitFullCalendarView.Week => GetEventsForWeek(events, selectedDate, culture), + BitFullCalendarView.Month => GetEventsForMonth(events, selectedDate, culture), + BitFullCalendarView.Year => GetEventsForYear(events, selectedDate, culture), + BitFullCalendarView.Agenda => GetEventsForMonth(events, selectedDate, culture), + _ => events.ToList() + }; + } + + /// + /// Smallest time t' >= on the same calendar day where + /// (t' - t'.Date) is a whole multiple of . + /// If is already on such a boundary, returns unchanged. + /// + public static DateTime CeilToMinuteInterval(DateTime dt, int intervalMinutes) + { + if (intervalMinutes <= 0) + throw new ArgumentOutOfRangeException(nameof(intervalMinutes)); + + var dayStart = dt.Date; + var minutesSinceDay = (dt - dayStart).TotalMinutes; + var slots = Math.Ceiling(minutesSinceDay / intervalMinutes); + return dayStart.AddMinutes(slots * intervalMinutes); + } + + /// + /// Largest time t' <= on the same calendar day where + /// (t' - t'.Date) is a whole multiple of . + /// + public static DateTime FloorToMinuteInterval(DateTime dt, int intervalMinutes) + { + if (intervalMinutes <= 0) + throw new ArgumentOutOfRangeException(nameof(intervalMinutes)); + + var dayStart = dt.Date; + var minutesSinceDay = (dt - dayStart).TotalMinutes; + var slots = Math.Floor(minutesSinceDay / intervalMinutes); + return dayStart.AddMinutes(slots * intervalMinutes); + } + + /// Stable key for filtering events by attendee (Id preferred, else full name). + public static string AttendeeFilterKey(BitFullCalendarAttendee a) + { + if (!string.IsNullOrWhiteSpace(a.Id)) + return "id:" + a.Id.Trim(); + if (!string.IsNullOrWhiteSpace(a.FullName)) + return "name:" + a.FullName.Trim().ToLowerInvariant(); + return ""; + } + + public static double GetCurrentTimeLineTopPx() + { + double minutes = DateTime.Now.TimeOfDay.TotalMinutes; + return minutes / 60.0 * HourHeightPx; + } + + /// + /// New event with only and + /// set (same default duration as the built-in add dialog: 30 minutes from the slot start). + /// + public static BitFullCalendarEvent CreateDraftEventForTimeSlot( + DateTime day, + int hour, + int startMinute = 0, + int durationMinutes = 30) + { + var start = day.Date.AddHours(hour).AddMinutes(startMinute); + return new BitFullCalendarEvent + { + StartDate = start, + EndDate = start.AddMinutes(durationMinutes) + }; + } + + /// + /// Computes the inclusive start/end dates for the visible range of the given view. + /// + public static (DateTime Start, DateTime End) GetDateRange( + BitFullCalendarView view, DateTime selectedDate, CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var cal = culture.Calendar; + + return view switch + { + BitFullCalendarView.Day => (selectedDate.Date, selectedDate.Date), + BitFullCalendarView.Week => + ( + StartOfWeek(selectedDate, culture), + StartOfWeek(selectedDate, culture).AddDays(6) + ), + BitFullCalendarView.Month or BitFullCalendarView.Agenda => + ( + cal.ToDateTime(cal.GetYear(selectedDate), cal.GetMonth(selectedDate), 1, 0, 0, 0, 0), + cal.AddMonths( + cal.ToDateTime(cal.GetYear(selectedDate), cal.GetMonth(selectedDate), 1, 0, 0, 0, 0), 1) + .AddDays(-1) + ), + BitFullCalendarView.Year => + ( + cal.ToDateTime(cal.GetYear(selectedDate), 1, 1, 0, 0, 0, 0), + cal.ToDateTime(cal.GetYear(selectedDate), cal.GetMonthsInYear(cal.GetYear(selectedDate)), + cal.GetDaysInMonth(cal.GetYear(selectedDate), cal.GetMonthsInYear(cal.GetYear(selectedDate))), + 0, 0, 0, 0) + ), + _ => (selectedDate.Date, selectedDate.Date) + }; + } + + public static string Capitalize(string str) + { + if (string.IsNullOrEmpty(str)) return ""; + return char.ToUpperInvariant(str[0]) + str[1..]; + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarState.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarState.cs new file mode 100644 index 0000000000..d8ec10eea2 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarState.cs @@ -0,0 +1,417 @@ +using System.Globalization; + +namespace Bit.BlazorUI; + +public class BitFullCalendarState +{ + private List _allEvents = []; + private List _filteredEvents = []; + private List _resources = []; + + public DateTime SelectedDate { get; private set; } = DateTime.Today; + public BitFullCalendarView View { get; private set; } = BitFullCalendarView.Month; + public BitFullCalendarMode Mode { get; private set; } = BitFullCalendarMode.Event; + public List SelectedColors { get; private set; } = []; + + /// When set, only events that include this attendee (by ) are shown. + public string? SelectedAttendeeKey { get; private set; } + public bool Use24HourFormat { get; private set; } = true; + public BitFullCalendarBadgeVariant BadgeVariant { get; private set; } = BitFullCalendarBadgeVariant.Colored; + public int StartOfDayHour { get; private set; } = 8; + public BitFullCalendarAgendaGroupBy AgendaModeGroupBy { get; private set; } = BitFullCalendarAgendaGroupBy.Date; + + /// Incremented when is invoked in agenda view so the list can scroll to today. + public ulong AgendaScrollToTodayNonce { get; private set; } + + public CultureInfo Culture { get; private set; } = CultureInfo.CurrentUICulture; + public bool IsRtl => Culture.TextInfo.IsRightToLeft; + + // Drag state + public BitFullCalendarEvent? DraggedEvent { get; set; } + public bool IsDragging => DraggedEvent != null; + + public IReadOnlyList Events => _filteredEvents; + public IReadOnlyList AllEvents => _allEvents; + public IReadOnlyList Resources => _resources; + + public event Action? OnStateChanged; + public event Action? OnDateRangeChanged; + + public void Initialize(List events, CultureInfo? culture = null) + { + _allEvents = [.. events]; + if (culture != null) + Culture = culture; + UpdateUI(); + } + + public void SetCulture(CultureInfo culture) + { + Culture = culture; + UpdateUI(); + } + + public void SetSelectedDate(DateTime date) + { + SelectedDate = date; + UpdateUI(); + NotifyDateRangeChanged(); + } + + public void SetView(BitFullCalendarView view) + { + View = ClampViewForMode(view, Mode); + UpdateUI(); + NotifyDateRangeChanged(); + } + + /// + /// Switches between Event and Timeline modes. When entering Timeline mode the active view + /// is clamped to Day / Week / Month (Year and Agenda are not supported in timeline mode). + /// + public void SetMode(BitFullCalendarMode mode) + { + if (Mode == mode) + return; + + Mode = mode; + var clamped = ClampViewForMode(View, mode); + if (clamped != View) + View = clamped; + + UpdateUI(); + NotifyDateRangeChanged(); + } + + private static BitFullCalendarView ClampViewForMode(BitFullCalendarView view, BitFullCalendarMode mode) + { + if (mode != BitFullCalendarMode.Timeline) + return view; + + return view switch + { + BitFullCalendarView.Day or BitFullCalendarView.Week or BitFullCalendarView.Month => view, + _ => BitFullCalendarView.Week + }; + } + + public void SetUse24HourFormat(bool value) + { + if (Use24HourFormat == value) + return; + Use24HourFormat = value; + NotifyStateChanged(); + } + + public void ToggleTimeFormat() + { + Use24HourFormat = !Use24HourFormat; + NotifyStateChanged(); + } + + public void SetBadgeVariant(BitFullCalendarBadgeVariant variant) + { + if (BadgeVariant == variant) + return; + BadgeVariant = variant; + NotifyStateChanged(); + } + + public void SetStartOfDayHour(int hour) + { + if (hour < 0 || hour > 16 || StartOfDayHour == hour) + return; + StartOfDayHour = hour; + NotifyStateChanged(); + } + + public void SetAgendaModeGroupBy(BitFullCalendarAgendaGroupBy groupBy) + { + if (AgendaModeGroupBy == groupBy) + return; + AgendaModeGroupBy = groupBy; + NotifyStateChanged(); + } + + public void NavigatePrevious() + { + SelectedDate = BitFullCalendarHelpers.NavigateDate(SelectedDate, View, false, Culture); + UpdateUI(); + NotifyDateRangeChanged(); + } + + public void NavigateNext() + { + SelectedDate = BitFullCalendarHelpers.NavigateDate(SelectedDate, View, true, Culture); + UpdateUI(); + NotifyDateRangeChanged(); + } + + public void GoToToday() + { + SelectedDate = DateTime.Today; + if (View == BitFullCalendarView.Agenda) + AgendaScrollToTodayNonce++; + UpdateUI(); + NotifyDateRangeChanged(); + } + + /// + /// Replaces the internal event list with the supplied collection when the contents differ. + /// Safe to call from OnParametersSet — it short-circuits when the list hasn't changed, + /// preventing infinite re-render loops. + /// + public void SyncEvents(List events) + { + if (EventsMatch(events)) + return; + + _allEvents = [.. events]; + ApplyFilters(); + NotifyStateChanged(); + } + + /// + /// Replaces the resource list shown by the resource timeline view. Safe to call from + /// OnParametersSet — it short-circuits when the supplied list matches the current one. + /// + public void SyncResources(IReadOnlyList? resources) + { + var next = resources is null ? new List() : [.. resources]; + if (ResourcesMatch(next)) + return; + + _resources = next; + NotifyStateChanged(); + } + + private bool ResourcesMatch(List resources) + { + if (_resources.Count != resources.Count) + return false; + + for (var i = 0; i < _resources.Count; i++) + { + if (!ReferenceEquals(_resources[i], resources[i])) + return false; + } + + return true; + } + + private bool EventsMatch(List events) + { + if (_allEvents.Count != events.Count) + return false; + + for (var i = 0; i < _allEvents.Count; i++) + { + if (!ReferenceEquals(_allEvents[i], events[i])) + return false; + } + + return true; + } + + public void AddEvent(BitFullCalendarEvent ev) + { + _allEvents.Add(ev); + UpdateUI(); + } + + public void UpdateEvent(BitFullCalendarEvent ev) + { + var idx = _allEvents.FindIndex(e => e.Id == ev.Id); + if (idx >= 0) _allEvents[idx] = ev; + UpdateUI(); + } + + public void RemoveEvent(string eventId) + { + _allEvents.RemoveAll(e => e.Id == eventId); + UpdateUI(); + } + + public void FilterByColor(string colorId) + { + if (string.IsNullOrWhiteSpace(colorId)) + return; + + var trimmed = colorId.Trim(); + var existing = SelectedColors.FindIndex(c => string.Equals(c, trimmed, StringComparison.OrdinalIgnoreCase)); + if (existing >= 0) + SelectedColors.RemoveAt(existing); + else + SelectedColors.Add(trimmed); + UpdateUI(); + } + + public void SetColorFilter(string? colorId) + { + SelectedColors.Clear(); + if (!string.IsNullOrWhiteSpace(colorId)) + SelectedColors.Add(colorId.Trim()); + + UpdateUI(); + } + + public void SetAttendeeFilter(string? attendeeKey) + { + SelectedAttendeeKey = string.IsNullOrWhiteSpace(attendeeKey) ? null : attendeeKey.Trim(); + UpdateUI(); + } + + public void UpdateUI() + { + ApplyFilters(); + NotifyStateChanged(); + } + + /// Distinct attendees on events visible in the current view/date range. + public IReadOnlyList<(string Key, string DisplayName)> GetAttendeesInCurrentView(string unnamedAttendeeText = "(Unnamed)") + { + var viewEvents = BitFullCalendarHelpers.GetEventsForView(_allEvents.ToList(), View, SelectedDate, Culture); + var map = new Dictionary(StringComparer.Ordinal); + foreach (var ev in viewEvents) + { + foreach (var a in ev.Attendees) + { + var key = BitFullCalendarHelpers.AttendeeFilterKey(a); + if (key.Length == 0) + continue; + if (map.ContainsKey(key)) + continue; + var label = string.IsNullOrWhiteSpace(a.FullName) + ? (string.IsNullOrWhiteSpace(a.Id) ? unnamedAttendeeText : a.Id.Trim()) + : a.FullName.Trim(); + map[key] = label; + } + } + + return map + .OrderBy(kv => kv.Value, StringComparer.CurrentCultureIgnoreCase) + .Select(kv => (kv.Key, kv.Value)) + .ToList(); + } + + public void ClearFilter() + { + SelectedColors.Clear(); + SelectedAttendeeKey = null; + _filteredEvents = [.. _allEvents]; + NotifyStateChanged(); + } + + private void ApplyFilters() + { + PruneInvalidAttendeeFilter(); + + var result = _allEvents.AsEnumerable(); + + if (SelectedColors.Count > 0) + result = result.Where(e => SelectedColors.Any(c => string.Equals(c, e.Color, StringComparison.OrdinalIgnoreCase))); + + if (SelectedAttendeeKey is not null) + result = result.Where(e => e.Attendees.Any(a => BitFullCalendarHelpers.AttendeeFilterKey(a) == SelectedAttendeeKey)); + + _filteredEvents = result.ToList(); + } + + private void PruneInvalidAttendeeFilter() + { + if (SelectedAttendeeKey is null) + return; + + var validKeys = BitFullCalendarHelpers + .GetEventsForView(_allEvents.ToList(), View, SelectedDate, Culture) + .SelectMany(e => e.Attendees) + .Select(BitFullCalendarHelpers.AttendeeFilterKey) + .Where(k => k.Length > 0) + .ToHashSet(StringComparer.Ordinal); + + if (!validKeys.Contains(SelectedAttendeeKey)) + SelectedAttendeeKey = null; + } + + // Drag-and-drop helpers + public void StartDrag(BitFullCalendarEvent ev) + { + DraggedEvent = ev; + NotifyStateChanged(); + } + + public void EndDrag() + { + if (DraggedEvent == null) + return; + + DraggedEvent = null; + NotifyStateChanged(); + } + + public void HandleDrop(DateTime targetDate, int? hour = null, int? minute = null) + => HandleDrop(targetDate, hour, minute, resourceId: null, applyResource: false); + + /// + /// Drops the currently dragged event onto a date/time and optionally re-assigns its + /// . When is + /// false the event keeps its existing resource. Pass as + /// null together with = true to clear the + /// resource (drop on the unassigned row). + /// + public void HandleDrop(DateTime targetDate, int? hour, int? minute, string? resourceId, bool applyResource) + { + if (DraggedEvent == null) return; + + var originalStart = DraggedEvent.StartDate; + var originalResource = DraggedEvent.Resource; + var duration = DraggedEvent.Duration; + + var newStart = targetDate.Date; + if (hour.HasValue) + newStart = newStart.AddHours(hour.Value).AddMinutes(minute ?? 0); + else + newStart = newStart.AddHours(originalStart.Hour).AddMinutes(originalStart.Minute); + + var newResource = applyResource ? resourceId : originalResource; + + var resourceChanged = applyResource && !string.Equals(originalResource ?? "", newResource ?? "", StringComparison.Ordinal); + + if (newStart == originalStart && !resourceChanged) + { + EndDrag(); + return; + } + + var updated = new BitFullCalendarEvent + { + Id = DraggedEvent.Id, + Title = DraggedEvent.Title, + Description = DraggedEvent.Description, + StartDate = newStart, + EndDate = newStart + duration, + Color = DraggedEvent.Color, + Resource = newResource, + Data = DraggedEvent.Data, + Attendees = [.. DraggedEvent.Attendees] + }; + + UpdateEvent(updated); + EndDrag(); + } + + private void NotifyStateChanged() => OnStateChanged?.Invoke(); + + private void NotifyDateRangeChanged() + { + if (OnDateRangeChanged is null) return; + var (start, end) = BitFullCalendarHelpers.GetDateRange(View, SelectedDate, Culture); + OnDateRangeChanged.Invoke(new BitFullCalendarDateChangeEventArgs + { + Start = start, + End = end, + View = View + }); + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/AgendaView/BitFcAgendaEvents.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/AgendaView/BitFcAgendaEvents.razor new file mode 100644 index 0000000000..110799cc31 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/AgendaView/BitFcAgendaEvents.razor @@ -0,0 +1,131 @@ +@namespace Bit.BlazorUI +@implements IDisposable +@inject IJSRuntime JS + +@{ + var monthEvents = BitFullCalendarHelpers.GetEventsForMonth(State.Events.ToList(), State.SelectedDate, State.Culture); + if (!string.IsNullOrWhiteSpace(_search)) + { + var q = _search.ToLowerInvariant(); + monthEvents = monthEvents.Where(e => + e.Title.Contains(q, StringComparison.OrdinalIgnoreCase) || + e.Description.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + List<(string? DateKey, string GroupTitle, List Items)> agendaGroups; + if (monthEvents.Count == 0) + { + agendaGroups = []; + } + else if (State.AgendaModeGroupBy == BitFullCalendarAgendaGroupBy.Date) + { + agendaGroups = monthEvents + .GroupBy(e => e.StartDate.ToString("yyyy-MM-dd")) + .OrderBy(g => g.Key) + .Select(g => + { + var title = DateTime.TryParse(g.Key, out var dt) + ? $"{dt.ToString("dddd", State.Culture)}, {BitFullCalendarHelpers.FormatCultureDate(dt, State.Culture)}" + : g.Key; + return (DateKey: (string?)g.Key, GroupTitle: title, Items: g.ToList()); + }) + .ToList(); + } + else + { + agendaGroups = monthEvents + .GroupBy(e => e.Color) + .OrderBy(g => ColorScheme.GetSortOrder(g.Key)) + .Select(g => (DateKey: (string?)null, GroupTitle: ColorScheme.GetLabel(g.Key), Items: g.ToList())) + .ToList(); + } +} + +
+ + + @if (agendaGroups.Count == 0) + { +
@Texts.NoEventsFound
+ } + else + { + @foreach (var (dateKey, groupTitle, items) in agendaGroups) + { +
+
@groupTitle
+ @foreach (var ev in items) + { +
+
+ @(ev.Attendees.Count > 0 ? ev.Attendees[0].Initials : "") +
+
+
@ev.Title
+
@ev.Description
+
+
+ @if (State.AgendaModeGroupBy == BitFullCalendarAgendaGroupBy.Date) + { + @BitFullCalendarHelpers.FormatTime(ev.StartDate, State.Use24HourFormat) + - + @BitFullCalendarHelpers.FormatTime(ev.EndDate, State.Use24HourFormat) + } + else + { + @BitFullCalendarHelpers.FormatCultureDate(ev.StartDate, State.Culture) + · + @BitFullCalendarHelpers.FormatTime(ev.StartDate, State.Use24HourFormat) + } +
+
+ } +
+ } + } +
+ +@if (_showDetails && _selectedEvent != null) +{ + +} + +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + + private string _search = ""; + private bool _showDetails; + private BitFullCalendarEvent? _selectedEvent; + private ulong _lastAgendaScrollNonce; + + protected override void OnInitialized() => State.OnStateChanged += Refresh; + private void Refresh() => InvokeAsync(StateHasChanged); + public void Dispose() => State.OnStateChanged -= Refresh; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + var nonce = State.AgendaScrollToTodayNonce; + if (nonce == _lastAgendaScrollNonce) + return; + + await BitFcAgendaScrollInterop.TryScrollToDateAsync(JS, "bfc-agenda-scroll", DateTime.Today); + _lastAgendaScrollNonce = nonce; + } + + private async Task ShowDetails(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + _showDetails = true; + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarDayView.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarDayView.razor new file mode 100644 index 0000000000..0c23b37b7b --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarDayView.razor @@ -0,0 +1,195 @@ +@namespace Bit.BlazorUI +@inject IJSRuntime JS + +@{ + var dayEvents = SingleDayEvents.Where(e => + e.StartDate.Date == State.SelectedDate.Date).ToList(); + var groupedEvents = BitFullCalendarHelpers.GroupEvents(dayEvents); + var currentEvents = SingleDayEvents.Where(e => + DateTime.Now >= e.StartDate && DateTime.Now <= e.EndDate).ToList(); +} + +
+
+
+ +
+ + @State.SelectedDate.ToString("ddd", State.Culture) + + + @BitFullCalendarHelpers.GetCulturalDayOfMonth(State.SelectedDate, State.Culture) + +
+
+ + + +
+ +
+ @for (int h = 0; h < 24; h++) + { + var hour = h; +
+ @if (h > 0) + { +
+ } +
+ +
+ } + + + +
+
+
+ +
+ + +
+
+ + @Texts.HappeningNowTitle +
+ @if (currentEvents.Count == 0) + { +
@Texts.NoAppointmentsNow
+ } + else + { + @foreach (var ev in currentEvents) + { + + } + } +
+
+
+ +@if (_showAddDialog) +{ + +} + +@if (_selectedEvent != null) +{ + +} + +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [CascadingParameter(Name = "OnAddClick")] public EventCallback OnAddClick { get; set; } + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + [Parameter] public List SingleDayEvents { get; set; } = []; + [Parameter] public List MultiDayEvents { get; set; } = []; + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private string? _timeGridScrollSignature; + + private bool _showAddDialog; + private DateTime _addStartDate; + private int _addStartHour; + + private BitFullCalendarEvent? _selectedEvent; + private int? _dragHour; + private int? _dragMinute; + + private async Task SelectEvent(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + } + private void CloseEventDetails() => _selectedEvent = null; + + private async Task OnHourClickAsync(int hour) + { + if (OnAddClick.HasDelegate) + { + var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot(State.SelectedDate, hour); + await OnAddClick.InvokeAsync(draft); + return; + } + + _addStartDate = State.SelectedDate; + _addStartHour = hour; + _showAddDialog = true; + } + + private async Task OnDropHour(int hour, int minute) + { + _dragHour = null; + _dragMinute = null; + await Notifier.HandleDropAsync(State.SelectedDate, hour, minute); + } + + private void OnDragEnterHour(int hour, int minute) + { + if (!State.IsDragging) + return; + + _dragHour = hour; + _dragMinute = minute; + } + + private string GetHourDropClass(int hour, int minute) + { + if (!State.IsDragging) + return string.Empty; + + return _dragHour == hour && _dragMinute == minute + ? (minute == 30 ? "bfc-drop-preview-half" : "bfc-drop-preview-hour") + : string.Empty; + } + + private string BuildTimeGridScrollSignature() => + $"{State.SelectedDate:yyyy-MM-dd}|{State.StartOfDayHour}"; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + var sig = BuildTimeGridScrollSignature(); + if (sig == _timeGridScrollSignature) + return; + + if (await BitFcTimeGridScrollInterop.TryScrollToStartOfDayAsync( + JS, + "bfc-day-timegrid-scroll", + State.StartOfDayHour)) + _timeGridScrollSignature = sig; + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarTimeline.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarTimeline.razor new file mode 100644 index 0000000000..a66cea86ca --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarTimeline.razor @@ -0,0 +1,33 @@ +@namespace Bit.BlazorUI +@implements IDisposable + +
+ @BitFullCalendarHelpers.FormatTime(DateTime.Now, State.Use24HourFormat) + + +
+ +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + + private double _positionPx; + private Timer? _timer; + + protected override void OnInitialized() + { + UpdatePosition(); + _timer = new Timer(_ => + { + UpdatePosition(); + InvokeAsync(StateHasChanged); + }, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); + } + + private void UpdatePosition() + { + _positionPx = BitFullCalendarHelpers.GetCurrentTimeLineTopPx(); + } + + public void Dispose() => _timer?.Dispose(); +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarWeekView.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarWeekView.razor new file mode 100644 index 0000000000..14b0378235 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarWeekView.razor @@ -0,0 +1,179 @@ +@namespace Bit.BlazorUI +@inject IJSRuntime JS + +@{ + var weekStart = BitFullCalendarHelpers.StartOfWeek(State.SelectedDate, State.Culture); + var weekDays = Enumerable.Range(0, 7).Select(i => weekStart.AddDays(i)).ToArray(); +} + +
+
+ @Texts.WeekMobileWarning +
+ + + +
+
+
+ @foreach (var day in weekDays) + { + var isToday = day.Date == DateTime.Today; +
+ @day.ToString("ddd", State.Culture) + @BitFullCalendarHelpers.GetCulturalDayOfMonth(day, State.Culture) +
+ } +
+ +
+ +
+ @foreach (var day in weekDays) + { + var d = day; + var dayEvents = SingleDayEvents.Where(e => + e.StartDate.Date == d.Date || e.EndDate.Date == d.Date).ToList(); + var grouped = BitFullCalendarHelpers.GroupEvents(dayEvents); + +
+ @for (int h = 0; h < 24; h++) + { + var hour = h; +
+ @if (h > 0) + { +
+ } +
+ +
+ } + + + + @if (d.Date == DateTime.Today) + { + + } +
+ } +
+
+
+
+ +@if (_showAddDialog) +{ + +} + +@if (_selectedEvent != null) +{ + +} + +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [CascadingParameter(Name = "OnAddClick")] public EventCallback OnAddClick { get; set; } + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + [Parameter] public List SingleDayEvents { get; set; } = []; + [Parameter] public List MultiDayEvents { get; set; } = []; + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private string? _timeGridScrollSignature; + + private bool _showAddDialog; + private DateTime _addDate; + private int _addHour; + + private BitFullCalendarEvent? _selectedEvent; + private DateTime? _dragDate; + private int? _dragHour; + private int? _dragMinute; + + private async Task SelectEvent(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + } + private void CloseEventDetails() => _selectedEvent = null; + + private async Task OnHourClickAsync(DateTime day, int hour) + { + if (OnAddClick.HasDelegate) + { + State.SetSelectedDate(day); + var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot(day, hour); + await OnAddClick.InvokeAsync(draft); + return; + } + + _addDate = day; + _addHour = hour; + _showAddDialog = true; + } + + private async Task OnDrop(DateTime day, int hour, int minute) + { + _dragDate = null; + _dragHour = null; + _dragMinute = null; + await Notifier.HandleDropAsync(day, hour, minute); + } + + private void OnDragEnterSlot(DateTime day, int hour, int minute) + { + if (!State.IsDragging) + return; + + _dragDate = day.Date; + _dragHour = hour; + _dragMinute = minute; + } + + private string GetWeekDropClass(DateTime day, int hour, int minute) + { + if (!State.IsDragging) + return string.Empty; + + return _dragDate == day.Date && _dragHour == hour && _dragMinute == minute + ? (minute == 30 ? "bfc-drop-preview-half" : "bfc-drop-preview-hour") + : string.Empty; + } + + private string BuildTimeGridScrollSignature() => + $"{State.SelectedDate:yyyy-MM-dd}|{State.StartOfDayHour}"; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + var sig = BuildTimeGridScrollSignature(); + if (sig == _timeGridScrollSignature) + return; + + if (await BitFcTimeGridScrollInterop.TryScrollToStartOfDayAsync( + JS, + "bfc-week-timegrid-scroll", + State.StartOfDayHour)) + _timeGridScrollSignature = sig; + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcDayViewMultiDayEventsRow.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcDayViewMultiDayEventsRow.razor new file mode 100644 index 0000000000..971c6fc1e8 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcDayViewMultiDayEventsRow.razor @@ -0,0 +1,52 @@ +@namespace Bit.BlazorUI + +@if (_multiDayForDay.Count > 0) +{ +
+ @foreach (var ev in _multiDayForDay) + { + var position = GetPosition(ev); + + } +
+} + +@if (_selectedEvent != null) +{ + +} + +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + [Parameter] public List MultiDayEvents { get; set; } = []; + [Parameter] public DateTime Date { get; set; } + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private List _multiDayForDay = []; + private BitFullCalendarEvent? _selectedEvent; + + protected override void OnParametersSet() + { + _multiDayForDay = BitFullCalendarHelpers.GetEventsForDay(MultiDayEvents, Date, true); + } + + private string GetPosition(BitFullCalendarEvent ev) + { + if (ev.StartDate.Date == Date.Date) return "first"; + if (ev.EndDate.Date == Date.Date) return "last"; + return "middle"; + } + + private async Task ShowEventDetails(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + } + private void CloseEventDetails() => _selectedEvent = null; +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcEventBlock.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcEventBlock.razor new file mode 100644 index 0000000000..a2af8685e8 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcEventBlock.razor @@ -0,0 +1,283 @@ +@namespace Bit.BlazorUI +@inject IJSRuntime JS +@implements IDisposable + +@{ + var displayStart = _previewStart ?? Event.StartDate; + var displayEnd = _previewEnd ?? Event.EndDate; + var durationMin = (displayEnd - displayStart).TotalMinutes; + var heightPx = (durationMin / 60.0) * BitFullCalendarHelpers.HourHeightPx; + var isDotVariant = State.BadgeVariant == BitFullCalendarBadgeVariant.Dot; + var colorClass = isDotVariant ? "dot-variant" : "bfc-color"; + var colorStyleVar = ColorScheme.GetColorStyle(Event.Color); + double translateYPx = 0; + if (_isResizing && string.Equals(_resizeDirection, "top", StringComparison.Ordinal) && _resizeBaseEvent != null && _previewStart.HasValue) + translateYPx = (displayStart - _resizeBaseEvent.StartDate).TotalMinutes / 60.0 * BitFullCalendarHelpers.HourHeightPx; + var inv = System.Globalization.CultureInfo.InvariantCulture; + var blockStyle = $"{colorStyleVar}height:{heightPx.ToString("F0", inv)}px;transform:translateY({translateYPx.ToString("F2", inv)}px);"; + var tooltip = BitFullCalendarHelpers.BuildEventTooltip(Event, State.Use24HourFormat); + var resizeClasses = ""; + if (_isResizing) + { + resizeClasses = "bfc-event-block-resizing"; + if (_resizeDirection is "top" or "bottom") + resizeClasses += $" bfc-resize-dir-{_resizeDirection}"; + } +} + +
+ +
+ + @if (EventTemplate != null) + { + @EventTemplate(Event) + } + else + { +
+ @if (isDotVariant) + { + + } + @Event.Title +
+ + @if (durationMin > 25) + { +
+ @BitFullCalendarHelpers.FormatTime(displayStart, State.Use24HourFormat) - @BitFullCalendarHelpers.FormatTime(displayEnd, State.Use24HourFormat) +
+ } + } + +
+ + @if (_isResizing) + { +
+ + @BitFullCalendarHelpers.FormatTime(displayStart, State.Use24HourFormat) - @BitFullCalendarHelpers.FormatTime(displayEnd, State.Use24HourFormat) +
+ } +
+ +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [Parameter] public BitFullCalendarEvent Event { get; set; } = default!; + [Parameter] public EventCallback OnSelected { get; set; } + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private readonly string _instanceId = Guid.NewGuid().ToString("N"); + private DotNetObjectReference? _dotNetRef; + private BitFullCalendarEvent? _resizeBaseEvent; + private string? _resizeDirection; + private DateTime? _previewStart; + private DateTime? _previewEnd; + private bool _resizeInitialized; + private bool _isResizing; + private DateTime _suppressClickUntilUtc; + private string _topHandleId => $"bfc-resize-top-{_instanceId}"; + private string _bottomHandleId => $"bfc-resize-bottom-{_instanceId}"; + + /// Minimum event length enforced by resize (minutes). + private const int MinEventDurationMinutes = 30; + + /// + /// Pointer movement below this (in minutes along the time axis) does not change start/end, + /// so the edge does not jump as soon as the user presses the handle. + /// + private const int ResizeDeadZoneMinutes = MinEventDurationMinutes / 2; + + private void OnDragStart() + { + if (_isResizing) + return; + + State.StartDrag(Event); + } + + private void OnDragEnd() => State.EndDrag(); + + private async Task OnClick() + { + if (DateTime.UtcNow <= _suppressClickUntilUtc || _isResizing) + return; + + await OnSelected.InvokeAsync(Event); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_resizeInitialized) + return; + + try + { + _dotNetRef ??= DotNetObjectReference.Create(this); + await JS.InvokeVoidAsync("BitBlazorUI.FullCalendar.initResize", _dotNetRef, _topHandleId, "top"); + await JS.InvokeVoidAsync("BitBlazorUI.FullCalendar.initResize", _dotNetRef, _bottomHandleId, "bottom"); + _resizeInitialized = true; + } + catch (JSException) + { + // BitBlazorUI.FullCalendar JS not yet available; will retry on the next render + } + } + + [JSInvokable] + public void OnResizeStart(string direction) + { + _isResizing = true; + _resizeDirection = direction; + _resizeBaseEvent = Event; + _previewStart = null; + _previewEnd = null; + _suppressClickUntilUtc = DateTime.UtcNow.AddMilliseconds(300); + State.EndDrag(); + _ = InvokeAsync(StateHasChanged); + } + + [JSInvokable] + public Task OnResizeMove(string direction, int deltaMinutes) + { + if (!_isResizing || _resizeBaseEvent == null) + return Task.CompletedTask; + + // Finger back at (or very near) the grab point → show the original span again and cancel + // any in-progress preview so the user can "undo" without releasing early. + if (deltaMinutes == 0 || Math.Abs(deltaMinutes) <= ResizeDeadZoneMinutes) + { + if (_previewStart.HasValue || _previewEnd.HasValue) + { + _previewStart = null; + _previewEnd = null; + return InvokeAsync(StateHasChanged); + } + return Task.CompletedTask; + } + + var effectiveDelta = deltaMinutes - Math.Sign(deltaMinutes) * ResizeDeadZoneMinutes; + const int slotMinutes = MinEventDurationMinutes; + var baseEvent = _resizeBaseEvent; + + var newStart = baseEvent.StartDate; + var newEnd = baseEvent.EndDate; + + if (direction == "top") + { + var maxStart = baseEvent.EndDate.AddMinutes(-slotMinutes); + var candidateStart = baseEvent.StartDate.AddMinutes(effectiveDelta); + newStart = effectiveDelta > 0 + ? BitFullCalendarHelpers.CeilToMinuteInterval(candidateStart, slotMinutes) + : BitFullCalendarHelpers.FloorToMinuteInterval(candidateStart, slotMinutes); + if (newStart > maxStart) + newStart = maxStart; + } + else + { + var minEnd = baseEvent.StartDate.AddMinutes(slotMinutes); + var candidateEnd = baseEvent.EndDate.AddMinutes(effectiveDelta); + newEnd = effectiveDelta > 0 + ? BitFullCalendarHelpers.CeilToMinuteInterval(candidateEnd, slotMinutes) + : BitFullCalendarHelpers.FloorToMinuteInterval(candidateEnd, slotMinutes); + if (newEnd < minEnd) + newEnd = minEnd; + } + + // Snapped range matches drag-start range → treat as restored original (clear preview). + if (newStart == baseEvent.StartDate && newEnd == baseEvent.EndDate) + { + if (_previewStart.HasValue || _previewEnd.HasValue) + { + _previewStart = null; + _previewEnd = null; + return InvokeAsync(StateHasChanged); + } + return Task.CompletedTask; + } + + var curStart = _previewStart ?? baseEvent.StartDate; + var curEnd = _previewEnd ?? baseEvent.EndDate; + if (newStart == curStart && newEnd == curEnd) + return Task.CompletedTask; + + _previewStart = newStart; + _previewEnd = newEnd; + return InvokeAsync(StateHasChanged); + } + + [JSInvokable] + public async Task OnResizeEnd() + { + try + { + if (_resizeBaseEvent != null && _previewStart.HasValue && _previewEnd.HasValue) + { + var s = _previewStart.Value; + var e = _previewEnd.Value; + if (s != _resizeBaseEvent.StartDate || e != _resizeBaseEvent.EndDate) + { + var b = _resizeBaseEvent; + var updated = new BitFullCalendarEvent + { + Id = b.Id, + Title = b.Title, + Description = b.Description, + StartDate = s, + EndDate = e, + Color = b.Color, + Resource = b.Resource, + Data = b.Data, + Attendees = [.. b.Attendees] + }; + + State.UpdateEvent(updated); + + await Notifier.NotifyAsync(new BitFullCalendarChangeEventArgs + { + Event = BitFullCalendarChangeNotifier.CloneEvent(updated), + OldEvent = BitFullCalendarChangeNotifier.CloneEvent(b), + Kind = BitFullCalendarChangeKind.Edit, + Source = BitFullCalendarChangeSource.Resize + }); + } + } + } + finally + { + _previewStart = null; + _previewEnd = null; + _isResizing = false; + _resizeBaseEvent = null; + _resizeDirection = null; + _suppressClickUntilUtc = DateTime.UtcNow.AddMilliseconds(300); + } + + await InvokeAsync(StateHasChanged); + } + + private void OnResizeTopStart() { } + private void OnResizeBottomStart() { } + + public void Dispose() => _dotNetRef?.Dispose(); +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcMiniCalendar.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcMiniCalendar.razor new file mode 100644 index 0000000000..95763bfb04 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcMiniCalendar.razor @@ -0,0 +1,77 @@ +@namespace Bit.BlazorUI + +@{ + var cells = BitFullCalendarHelpers.GetCalendarCells(_displayMonth, State.Culture); + var miniCal = State.Culture.Calendar; + var miniDtf = State.Culture.DateTimeFormat; + int miniYear = miniCal.GetYear(_displayMonth); + int miniMonth = miniCal.GetMonth(_displayMonth); + string miniTitle = $"{miniDtf.GetMonthName(miniMonth)} {miniYear}"; + var miniHeaders = BitFullCalendarHelpers.GetAbbreviatedWeekDayHeaders(State.Culture); +} + +
+
+ + @miniTitle + +
+ + + + @foreach (var d in miniHeaders) + { + + } + + + + @for (int row = 0; row < cells.Count / 7; row++) + { + + @for (int col = 0; col < 7; col++) + { + var cell = cells[row * 7 + col]; + var isToday = cell.Date.Date == DateTime.Today; + var isSelected = cell.Date.Date == State.SelectedDate.Date; + var cls = !cell.CurrentMonth ? "other-month" : isToday ? "today" : isSelected ? "selected" : ""; + + } + + } + +
@(d.Length > 2 ? d[..2] : d)
+ + @cell.Day + +
+
+ +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + + private DateTime _displayMonth; + + protected override void OnInitialized() + { + _displayMonth = State.Culture.Calendar.ToDateTime( + State.Culture.Calendar.GetYear(State.SelectedDate), + State.Culture.Calendar.GetMonth(State.SelectedDate), + 1, 0, 0, 0, 0); + } + + private void PrevMonth() => _displayMonth = State.Culture.Calendar.AddMonths(_displayMonth, -1); + private void NextMonth() => _displayMonth = State.Culture.Calendar.AddMonths(_displayMonth, 1); + + private void SelectDate(DateTime date) + { + State.SetSelectedDate(date); + var cal = State.Culture.Calendar; + _displayMonth = cal.ToDateTime(cal.GetYear(date), cal.GetMonth(date), 1, 0, 0, 0, 0); + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcRenderGroupedEvents.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcRenderGroupedEvents.razor new file mode 100644 index 0000000000..f7cbc59388 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcRenderGroupedEvents.razor @@ -0,0 +1,55 @@ +@namespace Bit.BlazorUI + +@{ + var inv = System.Globalization.CultureInfo.InvariantCulture; +} + +@foreach (var (group, groupIndex) in GroupedEvents.Select((g, i) => (g, i))) +{ + @foreach (var ev in group) + { + var style = BitFullCalendarHelpers.GetEventBlockStyle(ev, Day, groupIndex, GroupedEvents.Count); + + // Count lanes that actually overlap this event in time, to the left and to the right of + // this event's lane. Lanes that don't intersect the event's time range are ignored so a + // non-conflicting event keeps the full column width. + var leftConflicts = 0; + var rightConflicts = 0; + for (var i = 0; i < GroupedEvents.Count; i++) + { + if (i == groupIndex) + continue; + + var otherGroup = GroupedEvents[i]; + var conflicts = otherGroup.Any(other => + ev.StartDate < other.EndDate && ev.EndDate > other.StartDate); + if (!conflicts) + continue; + + if (i < groupIndex) leftConflicts++; else rightConflicts++; + } + + var depth = leftConflicts + rightConflicts + 1; + var rank = leftConflicts; + + // Offset each successive overlapping card by a percentage of the column width and let the + // card extend to the right edge so users can still see most of every card. The step is + // capped so even with many conflicts the narrowest card stays at least ~50% wide. + var step = depth > 1 ? Math.Min(20.0, 50.0 / (depth - 1)) : 0.0; + var left = rank * step; + var width = 100.0 - left; + var zIndex = 2 + rank; + +
+ +
+ } +} + +@code { + [Parameter] public List> GroupedEvents { get; set; } = []; + [Parameter] public DateTime Day { get; set; } + [Parameter] public EventCallback OnEventSelected { get; set; } + [Parameter] public RenderFragment? EventTemplate { get; set; } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcTimeColumn.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcTimeColumn.razor new file mode 100644 index 0000000000..3fd73ec4a5 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcTimeColumn.razor @@ -0,0 +1,19 @@ +@namespace Bit.BlazorUI + +
+ @for (int h = 0; h < 24; h++) + { + var hour = h; +
+ @if (hour > 0) + { + @BitFullCalendarHelpers.FormatHourLabel(hour, State.Use24HourFormat) + } +
+ } +
+ +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcWeekViewMultiDayEventsRow.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcWeekViewMultiDayEventsRow.razor new file mode 100644 index 0000000000..93e2ce267e --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcWeekViewMultiDayEventsRow.razor @@ -0,0 +1,96 @@ +@namespace Bit.BlazorUI + +@if (_rowCount > 0) +{ +
+
+ @foreach (var day in _weekDays) + { + var d = day; +
+ @for (int row = 0; row < _rowCount; row++) + { + var r = row; + var ev = _weekEvents.FirstOrDefault(e => + _eventRows.GetValueOrDefault(e.Id) == r && + e.StartDate.Date <= d.Date && e.EndDate.Date >= d.Date); + if (ev != null) + { + var position = ev.StartDate.Date == d.Date ? "first" + : ev.EndDate.Date == d.Date ? "last" : "middle"; + + } + else + { +
+ } + } +
+ } +
+
+} + +@if (_selectedEvent != null) +{ + +} + +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + [Parameter] public List MultiDayEvents { get; set; } = []; + [Parameter] public DateTime Date { get; set; } + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private DateTime[] _weekDays = []; + private List _weekEvents = []; + private Dictionary _eventRows = new(); + private int _rowCount; + private BitFullCalendarEvent? _selectedEvent; + + protected override void OnParametersSet() + { + _weekDays = BitFullCalendarHelpers.GetWeekDates(Date); + _weekEvents = BitFullCalendarHelpers.GetEventsForWeek(MultiDayEvents, Date) + .Where(e => e.IsMultiDay) + .OrderByDescending(e => (e.EndDate - e.StartDate).TotalDays) + .ThenBy(e => e.StartDate) + .ToList(); + + _eventRows = new Dictionary(); + var rowUsageByDay = _weekDays.ToDictionary(d => d.Date, _ => new HashSet()); + + foreach (var ev in _weekEvents) + { + var evDays = _weekDays + .Where(d => ev.StartDate.Date <= d.Date && ev.EndDate.Date >= d.Date) + .ToList(); + + for (int row = 0; ; row++) + { + if (evDays.All(d => !rowUsageByDay[d.Date].Contains(row))) + { + _eventRows[ev.Id] = row; + foreach (var d in evDays) + rowUsageByDay[d.Date].Add(row); + break; + } + } + } + + _rowCount = _eventRows.Count > 0 ? _eventRows.Values.Max() + 1 : 0; + } + + private async Task ShowEventDetails(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + } + private void CloseEventDetails() => _selectedEvent = null; +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcCalendarMonthView.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcCalendarMonthView.razor new file mode 100644 index 0000000000..5cf56ff0c1 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcCalendarMonthView.razor @@ -0,0 +1,32 @@ +@namespace Bit.BlazorUI + +@{ + var cells = BitFullCalendarHelpers.GetCalendarCells(State.SelectedDate, State.Culture); + var allEvents = MultiDayEvents.Concat(SingleDayEvents).ToList(); + var eventPositions = BitFullCalendarHelpers.CalculateMonthEventPositions(MultiDayEvents, SingleDayEvents, State.SelectedDate, State.Culture); + var weekDayHeaders = BitFullCalendarHelpers.GetWeekDayHeaders(State.Culture); +} + +
+
+ @foreach (var day in weekDayHeaders) + { +
@day
+ } +
+ +
+ @foreach (var cell in cells) + { + + } +
+
+ +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [Parameter] public List SingleDayEvents { get; set; } = []; + [Parameter] public List MultiDayEvents { get; set; } = []; + [Parameter] public RenderFragment? EventTemplate { get; set; } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcDayCell.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcDayCell.razor new file mode 100644 index 0000000000..45fd42ce3a --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcDayCell.razor @@ -0,0 +1,120 @@ +@namespace Bit.BlazorUI + +@{ + var isToday = Cell.Date.Date == DateTime.Today; + var cellEvents = BitFullCalendarHelpers.GetMonthCellEvents(Cell.Date, Events, EventPositions); +} + +
+ +
@Cell.Day
+ + +
+ @for (int i = 0; i < 3; i++) + { + var pos = i; + var item = cellEvents.FirstOrDefault(e => e.Position == pos); + @if (item.Event != null) + { + var position = GetBadgePosition(item.Event, Cell.Date); +
+ +
+ } + else + { +
+ } + } +
+ + @{ + var moreCount = cellEvents.Count - 3; + } + @if (Cell.CurrentMonth && moreCount > 0) + { +
+ +@moreCount @Texts.MoreEventsSuffix +
+ } +
+ +@if (_showEventList) +{ + +} + +@if (_showAddDialog) +{ + +} + +@if (_selectedEvent != null) +{ + +} + +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [CascadingParameter(Name = "OnAddClick")] public EventCallback OnAddClick { get; set; } + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + [Parameter] public BitFullCalendarCell Cell { get; set; } = default!; + [Parameter] public List Events { get; set; } = []; + [Parameter] public Dictionary EventPositions { get; set; } = new(); + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private bool _showEventList; + private bool _showAddDialog; + private BitFullCalendarEvent? _selectedEvent; + + private async Task ShowEventDetails(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + } + private void CloseEventDetails() => _selectedEvent = null; + + private async Task OnCellClick() + { + State.SetSelectedDate(Cell.Date); + if (OnAddClick.HasDelegate) + { + var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot( + Cell.Date, + DateTime.Now.Hour); + await OnAddClick.InvokeAsync(draft); + } + else + _showAddDialog = true; + } + + private string GetBadgePosition(BitFullCalendarEvent ev, DateTime cellDate) + { + if (ev.IsSingleDay) return "none"; + if (ev.StartDate.Date == cellDate.Date) return "first"; + if (ev.EndDate.Date == cellDate.Date) return "last"; + return "middle"; + } + + private void OnDragOver() { } + + private async Task OnDrop() + { + await Notifier.HandleDropAsync(Cell.Date); + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcEventBullet.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcEventBullet.razor new file mode 100644 index 0000000000..26bb081509 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcEventBullet.razor @@ -0,0 +1,9 @@ +@namespace Bit.BlazorUI + + + +@code { + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + + [Parameter] public string? Color { get; set; } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcMonthEventBadge.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcMonthEventBadge.razor new file mode 100644 index 0000000000..0bb6531a60 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcMonthEventBadge.razor @@ -0,0 +1,78 @@ +@namespace Bit.BlazorUI + +@{ + var positionClass = Position switch + { + "first" => "position-first", + "middle" => "position-middle", + "last" => "position-last", + _ => "" + }; + + var isDotVariant = State.BadgeVariant == BitFullCalendarBadgeVariant.Dot; + var colorClass = isDotVariant ? "dot-variant" : "bfc-color"; + var colorStyleVar = ColorScheme.GetColorStyle(Event.Color); + var showTitle = Position is "first" or "none"; + var showTime = Position is "last" or "none"; +} + +
+ + @if (EventTemplate != null) + { + @EventTemplate(Event) + } + else + { +
+ @if (isDotVariant && Position is not "middle" and not "last") + { + + } + @if (showTitle) + { + @Event.Title + } +
+ + @if (showTime) + { + @BitFullCalendarHelpers.FormatTime(Event.StartDate, State.Use24HourFormat) + } + } +
+ +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + [Parameter] public BitFullCalendarEvent Event { get; set; } = default!; + [Parameter] public DateTime CellDate { get; set; } + [Parameter] public string Position { get; set; } = "none"; + [Parameter] public EventCallback OnSelected { get; set; } + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private string MarginStyle + { + get + { + var isRtl = State.IsRtl; + return Position switch + { + "first" => isRtl ? "margin-left:-4px; margin-right:2px;" : "margin-left:2px; margin-right:-4px;", + "middle" => "margin-left:-4px; margin-right:-4px;", + "last" => isRtl ? "margin-left:2px; margin-right:-4px;" : "margin-left:-4px; margin-right:2px;", + _ => "margin:0 2px;" + }; + } + } + + private void OnDragStart() => State.StartDrag(Event); + private void OnDragEnd() => State.EndDrag(); +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineDayView.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineDayView.razor new file mode 100644 index 0000000000..6726664893 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineDayView.razor @@ -0,0 +1,238 @@ +@namespace Bit.BlazorUI +@inject IJSRuntime JS + +@* + Timeline mode - Day view. + Resources × 24 one-hour columns covering the selected date. +*@ + +@{ + const int hourWidth = BitFullCalendarHelpers.TimelineHourWidthPx; + const int laneHeight = 44; + const int laneGap = 4; + const int rowPadding = 4; + const int minRowHeight = 60; + + var resources = State.Resources; + var resourceIds = resources.Select(r => r.Id).Where(id => !string.IsNullOrEmpty(id)).ToList(); + var grouped = BitFullCalendarHelpers.GroupEventsByResourceForDay( + Events, State.SelectedDate, resourceIds, _unassignedKey); + + int LaneCountFor(string key) => + grouped.TryGetValue(key, out var lanes) && lanes.Count > 0 ? lanes.Count : 1; + + int RowHeight(int laneCount) + { + var content = (laneCount * laneHeight) + (Math.Max(0, laneCount - 1) * laneGap) + (rowPadding * 2); + return Math.Max(minRowHeight, content); + } + + var hasUnassigned = grouped.TryGetValue(_unassignedKey, out var u) && u.Count > 0; + var scrollHour = State.StartOfDayHour; +} + + + + +
+ @for (var h = 0; h < 24; h++) + { + var hour = h; + var isScrollTarget = hour == scrollHour; +
+ @BitFullCalendarHelpers.FormatHourLabel(hour, State.Use24HourFormat) +
+ } +
+
+ + + @{ var rowKey = resource.Id; } + @* Drop targets *@ + @for (var h = 0; h < 24; h++) + { + var hour = h; + var isPreviewHour = _dragResourceId == rowKey && _dragHour == hour && _dragMinute == 0; + var isPreviewHalf = _dragResourceId == rowKey && _dragHour == hour && _dragMinute == 30; +
+
+ +
+ } + + @* Events *@ + @if (grouped.TryGetValue(rowKey, out var lanes)) + { + @RenderLanes(lanes) + } +
+ + + @{ var rowKey = _unassignedKey; } + @for (var h = 0; h < 24; h++) + { + var hour = h; + var isPreviewHour = _dragResourceId == rowKey && _dragHour == hour && _dragMinute == 0; + var isPreviewHalf = _dragResourceId == rowKey && _dragHour == hour && _dragMinute == 30; +
+
+
+ } + @if (hasUnassigned) + { + @RenderLanes(grouped[_unassignedKey]) + } +
+
+ +@if (_showAddDialog) +{ + +} + +@if (_selectedEvent != null) +{ + +} + +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [CascadingParameter(Name = "OnAddClick")] public EventCallback OnAddClick { get; set; } + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + + [Parameter] public List Events { get; set; } = []; + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private const string _unassignedKey = "__bfc_unassigned__"; + private const int _laneHeight = 44; + private const int _laneGap = 4; + private const int _rowPadding = 4; + private const string _scrollContainerId = "bfc-tl-day-scroll"; + + private string? _scrollSignature; + + private BitFullCalendarEvent? _selectedEvent; + private bool _showAddDialog; + private DateTime _addStartDate; + private int _addStartHour; + + private string? _dragResourceId; + private int? _dragHour; + private int? _dragMinute; + + private RenderFragment RenderLanes(List> lanes) => builder => + { + var inv = System.Globalization.CultureInfo.InvariantCulture; + const int hourWidth = BitFullCalendarHelpers.TimelineHourWidthPx; + for (var li = 0; li < lanes.Count; li++) + { + var laneTop = _rowPadding + (li * (_laneHeight + _laneGap)); + foreach (var ev in lanes[li]) + { + var pos = BitFullCalendarHelpers.GetTimelineBlockPosition(ev, State.SelectedDate, hourWidth); + if (pos is not { } p) + continue; + + var style = $"left:{p.LeftPx.ToString("F2", inv)}px;width:{Math.Max(p.WidthPx, 12).ToString("F2", inv)}px;top:{laneTop}px;height:{_laneHeight}px;"; + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "bfc-tl-event-anchor"); + builder.AddAttribute(2, "style", style); + builder.OpenComponent(3); + builder.AddAttribute(4, "Event", ev); + builder.AddAttribute(5, "OnSelected", EventCallback.Factory.Create(this, SelectEvent)); + builder.AddAttribute(6, "EventTemplate", EventTemplate); + builder.AddAttribute(7, "PixelsPerMinute", hourWidth / 60.0); + builder.AddAttribute(8, "SnapMinutes", 30); + builder.CloseComponent(); + builder.CloseElement(); + } + } + }; + + private async Task SelectEvent(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + } + + private void CloseEventDetails() => _selectedEvent = null; + + private async Task OnSlotClickAsync(string resourceId, int hour, int minute) + { + if (OnAddClick.HasDelegate) + { + var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot(State.SelectedDate, hour, minute); + draft.Resource = resourceId == _unassignedKey ? null : resourceId; + await OnAddClick.InvokeAsync(draft); + return; + } + + _addStartDate = State.SelectedDate; + _addStartHour = hour; + _showAddDialog = true; + } + + private void OnDragEnter(string resourceId, int hour, int minute) + { + if (!State.IsDragging) return; + _dragResourceId = resourceId; + _dragHour = hour; + _dragMinute = minute; + } + + private async Task OnDrop(string resourceId, int hour, int minute) + { + _dragResourceId = null; + _dragHour = null; + _dragMinute = null; + var newResourceId = resourceId == _unassignedKey ? null : resourceId; + await Notifier.HandleResourceDropAsync(State.SelectedDate, hour, minute, newResourceId); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + var sig = $"{State.SelectedDate:yyyy-MM-dd}|{State.StartOfDayHour}"; + if (sig == _scrollSignature) return; + + if (await BitFcTimelineScrollInterop.TryScrollToTargetAsync(JS, _scrollContainerId)) + _scrollSignature = sig; + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineEventBlock.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineEventBlock.razor new file mode 100644 index 0000000000..7e5df3066d --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineEventBlock.razor @@ -0,0 +1,320 @@ +@namespace Bit.BlazorUI +@inject IJSRuntime JS +@implements IDisposable + +@{ + var displayStart = _previewStart ?? Event.StartDate; + var displayEnd = _previewEnd ?? Event.EndDate; + + var isDotVariant = State.BadgeVariant == BitFullCalendarBadgeVariant.Dot; + var colorClass = isDotVariant ? "dot-variant" : "bfc-color"; + var colorStyleVar = ColorScheme.GetColorStyle(Event.Color); + var passThrough = State.IsDragging && State.DraggedEvent?.Id != Event.Id; + + // While resizing we override the absolute placement of the anchor so the live preview + // tracks the snapped value instead of the original event time. + var inv = System.Globalization.CultureInfo.InvariantCulture; + var widthOverride = ""; + var translateXPx = 0.0; + if (_isResizing && _resizeBaseEvent != null && _previewStart.HasValue && _previewEnd.HasValue) + { + var newSpanPx = ToPx(displayEnd - displayStart); + if (newSpanPx < 12) newSpanPx = 12; + widthOverride = $"width:{newSpanPx.ToString("F2", inv)}px;"; + if (string.Equals(_resizeDirection, "start", StringComparison.Ordinal)) + translateXPx = ToPx(displayStart - _resizeBaseEvent.StartDate); + } + + var resizeClasses = ""; + if (_isResizing) + { + resizeClasses = "bfc-timeline-event-resizing"; + if (_resizeDirection is "start" or "end") + resizeClasses += $" bfc-resize-dir-{_resizeDirection}"; + } + + var blockStyle = $"{colorStyleVar}{widthOverride}transform:translateX({translateXPx.ToString("F2", inv)}px);"; + var canResize = PixelsPerMinute > 0; +} + +
+ + @if (canResize) + { +
+ } + + @if (EventTemplate != null) + { + @EventTemplate(Event) + } + else + { +
+ @if (isDotVariant) + { + + } + @Event.Title +
+
+ @BitFullCalendarHelpers.FormatTime(displayStart, State.Use24HourFormat) - @BitFullCalendarHelpers.FormatTime(displayEnd, State.Use24HourFormat) +
+ } + + @if (canResize) + { +
+ } + + @if (_isResizing) + { +
+ + @FormatPreview(displayStart, displayEnd) +
+ } +
+ +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + + [Parameter] public BitFullCalendarEvent Event { get; set; } = default!; + [Parameter] public EventCallback OnSelected { get; set; } + [Parameter] public RenderFragment? EventTemplate { get; set; } + + /// Pixels per minute on the timeline axis (e.g. 96/60 for hour columns, 56/1440 for day columns). + [Parameter] public double PixelsPerMinute { get; set; } + + /// Snap interval in minutes used while resizing. Hour timelines snap to 30 min, day timelines snap to a full day. + [Parameter] public int SnapMinutes { get; set; } = 30; + + /// Minimum length the event can be resized to (minutes). Defaults to . + [Parameter] public int? MinDurationMinutes { get; set; } + + /// When true, the resize preview shows the date instead of the time-of-day (used by month timeline). + [Parameter] public bool PreviewAsDate { get; set; } + + private readonly string _instanceId = Guid.NewGuid().ToString("N"); + private DotNetObjectReference? _dotNetRef; + private bool _resizeInitialized; + private bool _isResizing; + private string? _resizeDirection; + private BitFullCalendarEvent? _resizeBaseEvent; + private DateTime? _previewStart; + private DateTime? _previewEnd; + private DateTime _suppressClickUntilUtc; + + private string _startHandleId => $"bfc-tl-resize-start-{_instanceId}"; + private string _endHandleId => $"bfc-tl-resize-end-{_instanceId}"; + + private int EffectiveMinDurationMinutes => MinDurationMinutes ?? Math.Max(1, SnapMinutes); + + /// Pointer movement below this many pixels keeps the original time so the edge does not jump on press. + private double DeadZonePx => Math.Max(2.0, EffectiveMinDurationMinutes * PixelsPerMinute / 2.0); + + private double ToPx(TimeSpan span) => span.TotalMinutes * PixelsPerMinute; + + private void OnDragStart() + { + if (_isResizing) return; + State.StartDrag(Event); + } + + private void OnDragEnd() => State.EndDrag(); + + private async Task OnClick() + { + if (DateTime.UtcNow <= _suppressClickUntilUtc || _isResizing) return; + await OnSelected.InvokeAsync(Event); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_resizeInitialized || PixelsPerMinute <= 0) return; + + try + { + _dotNetRef ??= DotNetObjectReference.Create(this); + await JS.InvokeVoidAsync("BitBlazorUI.FullCalendar.initResizeHorizontal", _dotNetRef, _startHandleId, "start"); + await JS.InvokeVoidAsync("BitBlazorUI.FullCalendar.initResizeHorizontal", _dotNetRef, _endHandleId, "end"); + _resizeInitialized = true; + } + catch (JSException) + { + // BitFullCalendar JS not yet available; will retry on next render. + } + } + + [JSInvokable] + public void OnResizeStart(string direction) + { + _isResizing = true; + _resizeDirection = direction; + _resizeBaseEvent = Event; + _previewStart = null; + _previewEnd = null; + _suppressClickUntilUtc = DateTime.UtcNow.AddMilliseconds(300); + State.EndDrag(); + _ = InvokeAsync(StateHasChanged); + } + + [JSInvokable] + public Task OnResizeMove(string direction, double deltaPx) + { + if (!_isResizing || _resizeBaseEvent == null || PixelsPerMinute <= 0) + return Task.CompletedTask; + + // Inside the dead-zone treat the gesture as "no change" so users can press without + // immediately snapping the edge. Also clears any previously committed preview. + if (Math.Abs(deltaPx) <= DeadZonePx) + { + if (_previewStart.HasValue || _previewEnd.HasValue) + { + _previewStart = null; + _previewEnd = null; + return InvokeAsync(StateHasChanged); + } + return Task.CompletedTask; + } + + var sign = Math.Sign(deltaPx); + var effectivePx = deltaPx - sign * DeadZonePx; + var deltaMinutes = effectivePx / PixelsPerMinute; + + var snap = Math.Max(1, SnapMinutes); + var minDur = Math.Max(1, EffectiveMinDurationMinutes); + var baseEvent = _resizeBaseEvent; + + var newStart = baseEvent.StartDate; + var newEnd = baseEvent.EndDate; + + if (direction == "start") + { + var maxStart = baseEvent.EndDate.AddMinutes(-minDur); + var candidate = baseEvent.StartDate.AddMinutes(deltaMinutes); + newStart = deltaMinutes > 0 + ? BitFullCalendarHelpers.CeilToMinuteInterval(candidate, snap) + : BitFullCalendarHelpers.FloorToMinuteInterval(candidate, snap); + if (newStart > maxStart) + newStart = BitFullCalendarHelpers.FloorToMinuteInterval(maxStart, snap); + } + else + { + var minEnd = baseEvent.StartDate.AddMinutes(minDur); + var candidate = baseEvent.EndDate.AddMinutes(deltaMinutes); + newEnd = deltaMinutes > 0 + ? BitFullCalendarHelpers.CeilToMinuteInterval(candidate, snap) + : BitFullCalendarHelpers.FloorToMinuteInterval(candidate, snap); + if (newEnd < minEnd) + newEnd = BitFullCalendarHelpers.CeilToMinuteInterval(minEnd, snap); + } + + // Snapped range matches drag-start range → treat as restored original. + if (newStart == baseEvent.StartDate && newEnd == baseEvent.EndDate) + { + if (_previewStart.HasValue || _previewEnd.HasValue) + { + _previewStart = null; + _previewEnd = null; + return InvokeAsync(StateHasChanged); + } + return Task.CompletedTask; + } + + var curStart = _previewStart ?? baseEvent.StartDate; + var curEnd = _previewEnd ?? baseEvent.EndDate; + if (newStart == curStart && newEnd == curEnd) + return Task.CompletedTask; + + _previewStart = newStart; + _previewEnd = newEnd; + return InvokeAsync(StateHasChanged); + } + + [JSInvokable] + public async Task OnResizeEnd() + { + try + { + if (_resizeBaseEvent != null && _previewStart.HasValue && _previewEnd.HasValue) + { + var s = _previewStart.Value; + var e = _previewEnd.Value; + if (s != _resizeBaseEvent.StartDate || e != _resizeBaseEvent.EndDate) + { + var b = _resizeBaseEvent; + var updated = new BitFullCalendarEvent + { + Id = b.Id, + Title = b.Title, + Description = b.Description, + StartDate = s, + EndDate = e, + Color = b.Color, + Resource = b.Resource, + Data = b.Data, + Attendees = [.. b.Attendees] + }; + + State.UpdateEvent(updated); + + await Notifier.NotifyAsync(new BitFullCalendarChangeEventArgs + { + Event = BitFullCalendarChangeNotifier.CloneEvent(updated), + OldEvent = BitFullCalendarChangeNotifier.CloneEvent(b), + Kind = BitFullCalendarChangeKind.Edit, + Source = BitFullCalendarChangeSource.Resize + }); + } + } + } + finally + { + _previewStart = null; + _previewEnd = null; + _isResizing = false; + _resizeBaseEvent = null; + _resizeDirection = null; + _suppressClickUntilUtc = DateTime.UtcNow.AddMilliseconds(300); + } + + await InvokeAsync(StateHasChanged); + } + + private void NoOpResize() { /* pointerdown handled in JS via initResizeHorizontal */ } + + private string FormatPreview(DateTime start, DateTime end) + { + if (PreviewAsDate) + { + // Month timeline: end is exclusive (00:00 next day) so render the inclusive end. + var displayEnd = end.TimeOfDay == TimeSpan.Zero && end > start ? end.AddDays(-1) : end; + var startStr = start.ToString("MMM d", State.Culture); + var endStr = displayEnd.ToString("MMM d", State.Culture); + return startStr == endStr ? startStr : $"{startStr} - {endStr}"; + } + + return $"{BitFullCalendarHelpers.FormatTime(start, State.Use24HourFormat)} - {BitFullCalendarHelpers.FormatTime(end, State.Use24HourFormat)}"; + } + + public void Dispose() => _dotNetRef?.Dispose(); +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineLayout.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineLayout.razor new file mode 100644 index 0000000000..6f3ab18249 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineLayout.razor @@ -0,0 +1,109 @@ +@namespace Bit.BlazorUI + +@* + Shared scaffolding used by every timeline view. + + Layout (flex rows so the resource gutter can stay pinned across the full horizontal scroll): + - header row : flex row that sticks to the top; corner cell sticks to the inline-start + - body rows : flex rows; each starts with the sticky resource cell, followed by the lane area + + Each timeline view renders the time-axis header inside and the lane content + for every resource inside , while this component handles the + layout, sticky positioning, RTL flip, and the unassigned row. +*@ + +@{ + var resources = State.Resources; + var hasResources = resources.Count > 0; + var totalContentWidth = ColumnCount * ColumnWidthPx; + var totalGridWidth = ResourceColumnWidthPx + totalContentWidth; +} + +
+ @if (!hasResources) + { +
@Texts.NoResourcesMessage
+ } + else + { +
+
+ +
+
@Texts.ResourceColumnHeader
+
+ @HeaderContent +
+
+ + @foreach (var resource in resources) + { + var res = resource; + var rowHeight = RowHeightFor(res); + +
+
+
@res.Title
+ @if (!string.IsNullOrWhiteSpace(res.Subtitle)) + { +
@res.Subtitle
+ } +
+
+ @RowContent(res) +
+
+ } + + @if (HasUnassignedRow) + { + var unassignedHeight = UnassignedRowHeight; + +
+
+
@Texts.NoResourceLabel
+
+
+ @UnassignedRowContent +
+
+ } +
+
+ } +
+ +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + + /// Width of a single time-axis column in pixels. + [Parameter] public int ColumnWidthPx { get; set; } = BitFullCalendarHelpers.TimelineHourWidthPx; + + /// Total number of time-axis columns (e.g. 24 for day, 168 for week, days-in-month for month). + [Parameter] public int ColumnCount { get; set; } + + /// Width of the sticky resource gutter on the left. + [Parameter] public int ResourceColumnWidthPx { get; set; } = 200; + + /// DOM id assigned to the horizontal scroll container so views can scroll it via JS interop. + [Parameter] public string ScrollContainerId { get; set; } = "bfc-tl-scroll"; + + /// Time-axis header(s) rendered in the top-right cell. Total width must equal ColumnCount * ColumnWidthPx. + [Parameter] public RenderFragment? HeaderContent { get; set; } + + /// Row content for a single resource. + [Parameter] public RenderFragment RowContent { get; set; } = default!; + + /// Whether to render the trailing "Unassigned" row. + [Parameter] public bool HasUnassignedRow { get; set; } + + /// Content rendered inside the unassigned row. + [Parameter] public RenderFragment? UnassignedRowContent { get; set; } + + /// Resolves the height in pixels for a given resource row (lets each view stack overlapping events into lanes). + [Parameter] public Func RowHeightFor { get; set; } = _ => 56; + + /// Height in pixels for the unassigned row. + [Parameter] public int UnassignedRowHeight { get; set; } = 56; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineMonthView.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineMonthView.razor new file mode 100644 index 0000000000..477bc186f9 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineMonthView.razor @@ -0,0 +1,250 @@ +@namespace Bit.BlazorUI +@inject IJSRuntime JS + +@* + Timeline mode - Month view. + Resources × (one column per day in the active month). Day-precision drops re-place an event + on a different resource and date but keep its original time-of-day. +*@ + +@{ + const int dayWidth = BitFullCalendarHelpers.TimelineDayWidthPx; + const int laneHeight = 40; + const int laneGap = 4; + const int rowPadding = 4; + const int minRowHeight = 60; + + var culture = State.Culture; + var cal = culture.Calendar; + var year = cal.GetYear(State.SelectedDate); + var month = cal.GetMonth(State.SelectedDate); + var monthStart = cal.ToDateTime(year, month, 1, 0, 0, 0, 0); + var daysInMonth = cal.GetDaysInMonth(year, month); + var monthDays = Enumerable.Range(0, daysInMonth).Select(i => monthStart.AddDays(i)).ToArray(); + + var resources = State.Resources; + var resourceIds = resources.Select(r => r.Id).Where(id => !string.IsNullOrEmpty(id)).ToList(); + + // Group every event that touches the month into resource-keyed lists. + var perResource = BitFullCalendarHelpers.GroupEventsByResourceForMonth( + Events, monthStart, daysInMonth, resourceIds, _unassignedKey); + + int LaneCountFor(string resourceId) => + perResource.TryGetValue(resourceId, out var lanes) && lanes.Count > 0 ? lanes.Count : 1; + + int RowHeight(int laneCount) + { + var content = (laneCount * laneHeight) + (Math.Max(0, laneCount - 1) * laneGap) + (rowPadding * 2); + return Math.Max(minRowHeight, content); + } + + var hasUnassigned = perResource.TryGetValue(_unassignedKey, out var u) && u.Count > 0; + var unassignedLaneCount = hasUnassigned ? perResource[_unassignedKey].Count : 1; + + // If today falls inside the active month, scroll horizontally to today's day cell. + var todayIdx = Array.FindIndex(monthDays, d => d.Date == DateTime.Today); +} + + + + +
+ @for (var di = 0; di < monthDays.Length; di++) + { + var dayIndex = di; + var d = monthDays[dayIndex]; + var isToday = d.Date == DateTime.Today; + var dayNum = BitFullCalendarHelpers.GetCulturalDayOfMonth(d, culture); + var dowInitial = culture.DateTimeFormat.GetShortestDayName(d.DayOfWeek); + var isScrollTarget = dayIndex == todayIdx; +
+ @dayNum @dowInitial +
+ } +
+
+ + + @{ var rowKey = resource.Id; } + @for (var di = 0; di < daysInMonth; di++) + { + var dayIndex = di; + var day = monthDays[dayIndex]; + var leftPx = dayIndex * dayWidth; + var isPreview = _dragResourceId == rowKey && _dragDay == day.Date; +
+ +
+ } + + @if (perResource.TryGetValue(rowKey, out var lanes)) + { + @RenderLanes(lanes, monthStart, daysInMonth) + } +
+ + + @{ var rowKey = _unassignedKey; } + @for (var di = 0; di < daysInMonth; di++) + { + var dayIndex = di; + var day = monthDays[dayIndex]; + var leftPx = dayIndex * dayWidth; + var isPreview = _dragResourceId == rowKey && _dragDay == day.Date; +
+ } + @if (hasUnassigned) + { + @RenderLanes(perResource[_unassignedKey], monthStart, daysInMonth) + } +
+
+ +@if (_showAddDialog) +{ + +} + +@if (_selectedEvent != null) +{ + +} + +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [CascadingParameter(Name = "OnAddClick")] public EventCallback OnAddClick { get; set; } + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + + [Parameter] public List Events { get; set; } = []; + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private const string _unassignedKey = "__bfc_unassigned__"; + private const int _laneHeight = 40; + private const int _laneGap = 4; + private const int _rowPadding = 4; + private const string _scrollContainerId = "bfc-tl-month-scroll"; + + private string? _scrollSignature; + + private BitFullCalendarEvent? _selectedEvent; + private bool _showAddDialog; + private DateTime _addStartDate; + private int _addStartHour; + + private string? _dragResourceId; + private DateTime? _dragDay; + + private RenderFragment RenderLanes(List> lanes, DateTime monthStart, int daysInMonth) => builder => + { + var inv = System.Globalization.CultureInfo.InvariantCulture; + const int dayWidth = BitFullCalendarHelpers.TimelineDayWidthPx; + var monthEnd = monthStart.AddDays(daysInMonth); + + for (var li = 0; li < lanes.Count; li++) + { + var laneTop = _rowPadding + (li * (_laneHeight + _laneGap)); + foreach (var ev in lanes[li]) + { + var clippedStart = ev.StartDate < monthStart ? monthStart : ev.StartDate; + var clippedEnd = ev.EndDate > monthEnd ? monthEnd : ev.EndDate; + if (clippedEnd <= clippedStart) continue; + + // Use full-day boundaries for span (start of start day → start of next-after-end day). + var startDayIdx = (int)(clippedStart.Date - monthStart.Date).TotalDays; + var endExclusiveIdx = (int)(clippedEnd.Date - monthStart.Date).TotalDays + (clippedEnd.TimeOfDay > TimeSpan.Zero ? 1 : 0); + if (endExclusiveIdx <= startDayIdx) endExclusiveIdx = startDayIdx + 1; + + var leftPx = startDayIdx * dayWidth; + var widthPx = (endExclusiveIdx - startDayIdx) * dayWidth - 2; // -2 for visual gap + + var style = $"left:{leftPx.ToString("F2", inv)}px;width:{Math.Max(widthPx, 12).ToString("F2", inv)}px;top:{laneTop}px;height:{_laneHeight}px;"; + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "bfc-tl-event-anchor"); + builder.AddAttribute(2, "style", style); + builder.OpenComponent(3); + builder.AddAttribute(4, "Event", ev); + builder.AddAttribute(5, "OnSelected", EventCallback.Factory.Create(this, SelectEvent)); + builder.AddAttribute(6, "EventTemplate", EventTemplate); + builder.AddAttribute(7, "PixelsPerMinute", dayWidth / 1440.0); + builder.AddAttribute(8, "SnapMinutes", 1440); + builder.AddAttribute(9, "MinDurationMinutes", 1440); + builder.AddAttribute(10, "PreviewAsDate", true); + builder.CloseComponent(); + builder.CloseElement(); + } + } + }; + + private async Task SelectEvent(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + } + + private void CloseEventDetails() => _selectedEvent = null; + + private async Task OnSlotClickAsync(string resourceId, DateTime day) + { + if (OnAddClick.HasDelegate) + { + var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot(day, DateTime.Now.Hour); + draft.Resource = resourceId == _unassignedKey ? null : resourceId; + await OnAddClick.InvokeAsync(draft); + return; + } + + _addStartDate = day; + _addStartHour = DateTime.Now.Hour; + _showAddDialog = true; + } + + private void OnDragEnter(string resourceId, DateTime day) + { + if (!State.IsDragging) return; + _dragResourceId = resourceId; + _dragDay = day.Date; + } + + private async Task OnDrop(string resourceId, DateTime day) + { + _dragResourceId = null; + _dragDay = null; + var newResourceId = resourceId == _unassignedKey ? null : resourceId; + // Day-precision drop: keep the original time of day (passing null hour/minute makes + // HandleResourceDropAsync preserve the dragged event's time). + await Notifier.HandleResourceDropAsync(day, hour: null, minute: null, newResourceId); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + var sig = $"{State.SelectedDate:yyyy-MM}|{DateTime.Today:yyyy-MM-dd}"; + if (sig == _scrollSignature) return; + + if (await BitFcTimelineScrollInterop.TryScrollToTargetAsync(JS, _scrollContainerId)) + _scrollSignature = sig; + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineWeekView.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineWeekView.razor new file mode 100644 index 0000000000..ce90e3e356 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineWeekView.razor @@ -0,0 +1,296 @@ +@namespace Bit.BlazorUI +@inject IJSRuntime JS + +@* + Timeline mode - Week view. + Resources × (7 days × 24 hours) hour-precision columns. + Two-row time header: top row spans each day, bottom row shows hourly slots. +*@ + +@{ + const int hourWidth = BitFullCalendarHelpers.TimelineHourWidthPx; + const int laneHeight = 44; + const int laneGap = 4; + const int rowPadding = 4; + const int minRowHeight = 60; + + var weekStart = BitFullCalendarHelpers.StartOfWeek(State.SelectedDate, State.Culture); + var weekDays = Enumerable.Range(0, 7).Select(i => weekStart.AddDays(i)).ToArray(); + + var resources = State.Resources; + var resourceIds = resources.Select(r => r.Id).Where(id => !string.IsNullOrEmpty(id)).ToList(); + + // Lanes per (resourceId, dayIndex) + var perDay = weekDays.Select(d => + BitFullCalendarHelpers.GroupEventsByResourceForDay(Events, d, resourceIds, _unassignedKey) + ).ToArray(); + + int LaneCountFor(string resourceId) + { + var max = 1; + foreach (var dayMap in perDay) + { + if (dayMap.TryGetValue(resourceId, out var lanes) && lanes.Count > max) + max = lanes.Count; + } + return max; + } + + int RowHeight(int laneCount) + { + var content = (laneCount * laneHeight) + (Math.Max(0, laneCount - 1) * laneGap) + (rowPadding * 2); + return Math.Max(minRowHeight, content); + } + + var hasUnassigned = perDay.Any(m => m.TryGetValue(_unassignedKey, out var l) && l.Count > 0); + var unassignedLaneCount = hasUnassigned ? LaneCountFor(_unassignedKey) : 1; + var totalColumns = 7 * 24; + + // Pick the day and hour cell that should be in view after render: today's day if it falls + // inside the visible week, otherwise the first day. Within that day, the start-of-day hour. + var todayIdx = Array.FindIndex(weekDays, d => d.Date == DateTime.Today); + var scrollDayIdx = todayIdx >= 0 ? todayIdx : 0; + var scrollHour = State.StartOfDayHour; +} + + + + +
+ @foreach (var d in weekDays) + { + var isToday = d.Date == DateTime.Today; +
+ @d.ToString("ddd", State.Culture) + @d.ToString("M/d", State.Culture) +
+ } +
+
+ @for (var di = 0; di < 7; di++) + { + var dayIndex = di; + @for (var h = 0; h < 24; h++) + { + var hour = h; + var isScrollTarget = dayIndex == scrollDayIdx && hour == scrollHour; +
+ @BitFullCalendarHelpers.FormatHourLabel(hour, State.Use24HourFormat) +
+ } + } +
+
+ + + @{ var rowKey = resource.Id; } + @for (var di = 0; di < 7; di++) + { + var dayIndex = di; + var day = weekDays[dayIndex]; + var dayOffsetPx = dayIndex * 24 * hourWidth; + var dayMap = perDay[dayIndex]; + + @for (var h = 0; h < 24; h++) + { + var hour = h; + var leftPx = dayOffsetPx + (hour * hourWidth); + var isPreviewHour = _dragResourceId == rowKey && _dragDay == day.Date && _dragHour == hour && _dragMinute == 0; + var isPreviewHalf = _dragResourceId == rowKey && _dragDay == day.Date && _dragHour == hour && _dragMinute == 30; +
+
+ +
+ } + + @if (dayMap.TryGetValue(rowKey, out var lanes)) + { + @RenderLanes(lanes, day, dayOffsetPx) + } + } +
+ + + @{ var rowKey = _unassignedKey; } + @for (var di = 0; di < 7; di++) + { + var dayIndex = di; + var day = weekDays[dayIndex]; + var dayOffsetPx = dayIndex * 24 * hourWidth; + var dayMap = perDay[dayIndex]; + + @for (var h = 0; h < 24; h++) + { + var hour = h; + var leftPx = dayOffsetPx + (hour * hourWidth); + var isPreviewHour = _dragResourceId == rowKey && _dragDay == day.Date && _dragHour == hour && _dragMinute == 0; + var isPreviewHalf = _dragResourceId == rowKey && _dragDay == day.Date && _dragHour == hour && _dragMinute == 30; +
+
+
+ } + + @if (dayMap.TryGetValue(rowKey, out var lanes)) + { + @RenderLanes(lanes, day, dayOffsetPx) + } + } +
+
+ +@if (_showAddDialog) +{ + +} + +@if (_selectedEvent != null) +{ + +} + +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [CascadingParameter(Name = "OnAddClick")] public EventCallback OnAddClick { get; set; } + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + + [Parameter] public List Events { get; set; } = []; + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private const string _unassignedKey = "__bfc_unassigned__"; + private const int _laneHeight = 44; + private const int _laneGap = 4; + private const int _rowPadding = 4; + private const string _scrollContainerId = "bfc-tl-week-scroll"; + + private string? _scrollSignature; + + private BitFullCalendarEvent? _selectedEvent; + private bool _showAddDialog; + private DateTime _addStartDate; + private int _addStartHour; + + private string? _dragResourceId; + private DateTime? _dragDay; + private int? _dragHour; + private int? _dragMinute; + + private RenderFragment RenderLanes(List> lanes, DateTime day, int dayOffsetPx) => builder => + { + var inv = System.Globalization.CultureInfo.InvariantCulture; + const int hourWidth = BitFullCalendarHelpers.TimelineHourWidthPx; + + for (var li = 0; li < lanes.Count; li++) + { + var laneTop = _rowPadding + (li * (_laneHeight + _laneGap)); + foreach (var ev in lanes[li]) + { + var pos = BitFullCalendarHelpers.GetTimelineBlockPosition(ev, day, hourWidth); + if (pos is not { } p) + continue; + + var style = $"left:{(dayOffsetPx + p.LeftPx).ToString("F2", inv)}px;width:{Math.Max(p.WidthPx, 12).ToString("F2", inv)}px;top:{laneTop}px;height:{_laneHeight}px;"; + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "bfc-tl-event-anchor"); + builder.AddAttribute(2, "style", style); + builder.OpenComponent(3); + builder.AddAttribute(4, "Event", ev); + builder.AddAttribute(5, "OnSelected", EventCallback.Factory.Create(this, SelectEvent)); + builder.AddAttribute(6, "EventTemplate", EventTemplate); + builder.AddAttribute(7, "PixelsPerMinute", hourWidth / 60.0); + builder.AddAttribute(8, "SnapMinutes", 30); + builder.CloseComponent(); + builder.CloseElement(); + } + } + }; + + private async Task SelectEvent(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + } + + private void CloseEventDetails() => _selectedEvent = null; + + private async Task OnSlotClickAsync(string resourceId, DateTime day, int hour, int minute) + { + if (OnAddClick.HasDelegate) + { + var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot(day, hour, minute); + draft.Resource = resourceId == _unassignedKey ? null : resourceId; + await OnAddClick.InvokeAsync(draft); + return; + } + + _addStartDate = day; + _addStartHour = hour; + _showAddDialog = true; + } + + private void OnDragEnter(string resourceId, DateTime day, int hour, int minute) + { + if (!State.IsDragging) return; + _dragResourceId = resourceId; + _dragDay = day.Date; + _dragHour = hour; + _dragMinute = minute; + } + + private async Task OnDrop(string resourceId, DateTime day, int hour, int minute) + { + _dragResourceId = null; + _dragDay = null; + _dragHour = null; + _dragMinute = null; + var newResourceId = resourceId == _unassignedKey ? null : resourceId; + await Notifier.HandleResourceDropAsync(day, hour, minute, newResourceId); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + var weekStart = BitFullCalendarHelpers.StartOfWeek(State.SelectedDate, State.Culture); + var sig = $"{weekStart:yyyy-MM-dd}|{State.StartOfDayHour}|{DateTime.Today:yyyy-MM-dd}"; + if (sig == _scrollSignature) return; + + if (await BitFcTimelineScrollInterop.TryScrollToTargetAsync(JS, _scrollContainerId)) + _scrollSignature = sig; + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/YearView/BitFcCalendarYearView.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/YearView/BitFcCalendarYearView.razor new file mode 100644 index 0000000000..7550c92602 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/YearView/BitFcCalendarYearView.razor @@ -0,0 +1,86 @@ +@namespace Bit.BlazorUI + +@{ + var allEvents = MultiDayEvents.Concat(SingleDayEvents).ToList(); + var cal = State.Culture.Calendar; + var dtf = State.Culture.DateTimeFormat; + int culturalYear = cal.GetYear(State.SelectedDate); + var months = Enumerable.Range(1, 12) + .Select(m => cal.ToDateTime(culturalYear, m, 1, 0, 0, 0, 0)) + .ToArray(); + var yearWeekDayHeaders = BitFullCalendarHelpers.GetAbbreviatedWeekDayHeaders(State.Culture); +} + +
+ @foreach (var monthDate in months) + { + var cells = BitFullCalendarHelpers.GetCalendarCells(monthDate, State.Culture); + var monthIndex = cal.GetMonth(monthDate); + var monthName = dtf.GetMonthName(monthIndex); +
+
+ @monthName +
+
+ @foreach (var wd in yearWeekDayHeaders) + { +
@(wd.Length > 2 ? wd[..2] : wd)
+ } +
+
+ @foreach (var cell in cells) + { + var cellCulturalMonth = cal.GetMonth(cell.Date); + var isCurrentMonth = cellCulturalMonth == monthIndex + && cal.GetYear(cell.Date) == culturalYear; + var isToday = cell.Date.Date == DateTime.Today; + var dayEvents = allEvents.Where(e => e.StartDate.Date == cell.Date.Date).ToList(); + var hasEvents = isCurrentMonth && dayEvents.Count > 0; + +
+ @cell.Day + @if (hasEvents) + { +
+ @foreach (var ev in dayEvents.Take(3)) + { + + } +
+ } +
+ } +
+
+ } +
+ +@if (_showEventList) +{ + +} + +@code { + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [Parameter] public List SingleDayEvents { get; set; } = []; + [Parameter] public List MultiDayEvents { get; set; } = []; + + private bool _showEventList; + private DateTime _eventListDate; + private List _eventListEvents = []; + + private void GoToMonth(DateTime month) + { + State.SetSelectedDate(month); + State.SetView(BitFullCalendarView.Month); + } + + private void ShowEventsForDay(DateTime date, List events) + { + _eventListDate = date; + _eventListEvents = events; + _showEventList = true; + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss index ca854411fa..24e1d480eb 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss @@ -4,6 +4,7 @@ @import "../Components/DataGrid/Pagination/BitDataGridPaginator.scss"; @import "../Components/ErrorBoundary/BitErrorBoundary.scss"; @import "../Components/Flag/BitFlag.scss"; +@import "../Components/FullCalendar/BitFullCalendar.scss"; @import "../Components/InfiniteScrolling/BitInfiniteScrolling.scss"; @import "../Components/Map/BitMap.scss"; @import "../Components/MarkdownEditor/BitMarkdownEditor.scss"; diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/FullCalendar/BitFullCalendarDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/FullCalendar/BitFullCalendarDemo.razor new file mode 100644 index 0000000000..fbbc5237b6 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/FullCalendar/BitFullCalendarDemo.razor @@ -0,0 +1,123 @@ +@page "/components/fullcalendar" + + + + + + + To use this component, you need to install the + + + + nuget package, as described in the Optional steps of the + Getting started page. + + + + + The BitFullCalendar brings a complete scheduling experience to Blazor: multiple view modes + (day, week, month, year, agenda, and a resource timeline), built-in event create/edit/delete, + drag-and-drop, resize, filtering, theming that follows the Bit theme (including dark mode), + and culture-aware date rendering. + Explore a live calendar below — switch views, add events, and drag them around. + +
+ +
+ + +
The simplest usage of a BitFullCalendar is by passing a list of events.
+
+ +
+ + +
+ BitFullCalendar follows the application's Bit theme automatically. Switch the Bit theme + (for example to a dark theme) and the calendar surfaces, text, and accent colors update + with it — there is no component-level theme or dark-mode switch. +
+
+ +
+ + +
+ Drive initial preferences from code with the Options parameter — 12/24-hour + time format, day start hour, badge variant, and agenda grouping. +
+
+ +
+ + +
Customize the event card content for each view with the per-view templates.
+
+ +
+ + +
+ Supply Resources to enable the Timeline mode, where rows are resources (rooms, machines, + people) and columns are time. Drag events between rows to reassign their resource. +
+
+ +
+ + +
The OnChange callback is raised whenever a user adds, edits, or deletes an event.
+
+ +
+ Last change: @(lastChange ?? "—") +
+ + +
+ Render the calendar with a localized culture (here Persian, fa-IR) and + override UI strings with a customized BitFullCalendarTexts instance. +
+
+ +
+ + +
+ Hide the built-in color/attendee filters and the settings gear to provide your own + external controls or a minimal header. +
+
+ +
+
+
+ +@code { + private RenderFragment EventCard => ev => + @
+ @ev.Title + @if (!string.IsNullOrWhiteSpace(ev.Description)) + { + @ev.Description + } +
; + + private RenderFragment MonthBadge => ev => @📌 @ev.Title; +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/FullCalendar/BitFullCalendarDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/FullCalendar/BitFullCalendarDemo.razor.cs new file mode 100644 index 0000000000..72daba0e03 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/FullCalendar/BitFullCalendarDemo.razor.cs @@ -0,0 +1,383 @@ +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.FullCalendar; + +public partial class BitFullCalendarDemo +{ + private readonly List componentParameters = + [ + new() + { + Name = "Events", + Type = "List?", + DefaultValue = "null", + Description = "List of calendar events to display.", + }, + new() + { + Name = "Culture", + Type = "CultureInfo?", + DefaultValue = "CultureInfo.CurrentUICulture", + Description = "Sets calendar/date rendering and formatting. Do not use with @rendermode=\"InteractiveServer\" — use CultureName instead.", + }, + new() + { + Name = "CultureName", + Type = "string?", + DefaultValue = "null", + Description = "Culture name shortcut (e.g. \"fa-IR\", \"ar-SA\", \"fr-FR\"). Takes precedence over Culture when both are supplied.", + }, + new() + { + Name = "Texts", + Type = "BitFullCalendarTexts", + DefaultValue = "new()", + Description = "Custom UI strings for labels, placeholders, action buttons, aria labels, and validation messages.", + }, + new() + { + Name = "EventColorOptions", + Type = "IReadOnlyList?", + DefaultValue = "null", + Description = "Ordered list of event colors shown in pickers, filters, agenda headers, badges, and bullets.", + }, + new() + { + Name = "Resources", + Type = "IReadOnlyList?", + DefaultValue = "null", + Description = "Resources displayed as rows in Timeline mode. Each event's Resource property is matched against the resource Id. The Timeline mode tab is hidden when null or empty.", + }, + new() + { + Name = "InitialMode", + Type = "BitFullCalendarMode?", + DefaultValue = "null", + Description = "Initial layout mode. Event shows day/week/month/year/agenda views. Timeline shows resources × time grid and requires Resources to be non-empty.", + LinkType = LinkType.Link, + Href = "#mode-enum", + }, + new() + { + Name = "OnChange", + Type = "EventCallback", + DefaultValue = "", + Description = "Raised when a user adds, edits, or deletes an event (Kind: Add, Edit, Delete; Source: Dialog, Drag, Resize, Delete).", + }, + new() + { + Name = "OnAddClick", + Type = "EventCallback", + DefaultValue = "", + Description = "When assigned, the built-in add dialog is suppressed. Receives a draft event with pre-filled dates from the clicked slot.", + }, + new() + { + Name = "OnEventClick", + Type = "EventCallback", + DefaultValue = "", + Description = "When assigned, the built-in event details dialog is suppressed when an event is clicked. Receives the clicked event.", + }, + new() + { + Name = "OnDateChange", + Type = "EventCallback", + DefaultValue = "", + Description = "Raised when the visible date range changes after prev/next/today navigation or a view switch. Payload includes inclusive Start/End and the active View.", + }, + new() + { + Name = "HideFilters", + Type = "bool", + DefaultValue = "false", + Description = "When true, hides the built-in color and attendee filter dropdowns. Consumers provide their own filter UI and pass pre-filtered events.", + }, + new() + { + Name = "HideSettings", + Type = "bool", + DefaultValue = "false", + Description = "When true, hides the built-in settings gear button. Settings can still be driven programmatically through Options.", + }, + new() + { + Name = "Options", + Type = "BitFullCalendarOptions", + DefaultValue = "new()", + Description = "Initial preferences — 12/24-hour time format, badge variant, day start hour, and agenda grouping.", + }, + new() + { + Name = "DayEventTemplate", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "Replaces the default event card content inside day-view time-grid blocks.", + }, + new() + { + Name = "WeekEventTemplate", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "Replaces the default event card content inside week-view time-grid blocks.", + }, + new() + { + Name = "MonthEventTemplate", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "Replaces the default event badge content inside month-view cells.", + }, + new() + { + Name = "TimelineEventTemplate", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "Replaces the default event card content inside Timeline mode blocks.", + }, + ]; + + private readonly List componentSubEnums = + [ + new() + { + Id = "mode-enum", + Name = "BitFullCalendarMode", + Description = "Top-level layout mode for the calendar surface.", + Items = + [ + new() { Name = "Event", Description = "Day, week, month, year, and agenda views on a date grid.", Value = "0" }, + new() { Name = "Timeline", Description = "Resource-centric layout: resources × time grid.", Value = "1" }, + ] + }, + new() + { + Id = "view-enum", + Name = "BitFullCalendarView", + Description = "Active view inside the current mode.", + Items = + [ + new() { Name = "Day", Description = "Single-day detailed view.", Value = "0" }, + new() { Name = "Week", Description = "7-day view with hourly time slots.", Value = "1" }, + new() { Name = "Month", Description = "Month grid with multi-day events.", Value = "2" }, + new() { Name = "Year", Description = "12-month overview.", Value = "3" }, + new() { Name = "Agenda", Description = "Searchable list grouped by date or color.", Value = "4" }, + ] + }, + new() + { + Id = "badge-variant-enum", + Name = "BitFullCalendarBadgeVariant", + Description = "Badge display style in the month view.", + Items = + [ + new() { Name = "Colored", Description = "Colored badge.", Value = "0" }, + new() { Name = "Dot", Description = "Colored dot bullet.", Value = "1" }, + ] + }, + new() + { + Id = "agenda-group-by-enum", + Name = "BitFullCalendarAgendaGroupBy", + Description = "How events are grouped in the agenda view.", + Items = + [ + new() { Name = "Date", Description = "Group agenda items by date.", Value = "0" }, + new() { Name = "Color", Description = "Group agenda items by color.", Value = "1" }, + ] + }, + new() + { + Id = "change-kind-enum", + Name = "BitFullCalendarChangeKind", + Description = "Identifies the kind of change applied to a calendar event.", + Items = + [ + new() { Name = "Add", Description = "An event was added.", Value = "0" }, + new() { Name = "Edit", Description = "An event was edited.", Value = "1" }, + new() { Name = "Delete", Description = "An event was deleted.", Value = "2" }, + ] + }, + new() + { + Id = "change-source-enum", + Name = "BitFullCalendarChangeSource", + Description = "Identifies where a calendar event change originated from in the UI.", + Items = + [ + new() { Name = "Dialog", Description = "From the add/edit dialog.", Value = "0" }, + new() { Name = "Drag", Description = "From a drag-and-drop move.", Value = "1" }, + new() { Name = "Resize", Description = "From resizing an event block.", Value = "2" }, + new() { Name = "Delete", Description = "From the delete action.", Value = "3" }, + ] + }, + ]; + + + + private readonly List basicEvents = CreateEvents(); + private readonly List themeEvents = CreateEvents(); + private readonly List optionsEvents = CreateEvents(); + private readonly List templateEvents = CreateEvents(); + private readonly List changeEvents = CreateEvents(); + private readonly List localizationEvents = CreateEvents(); + + private readonly BitFullCalendarOptions options = new() + { + Use24HourFormat = false, + StartOfDayHour = 7, + BadgeVariant = BitFullCalendarBadgeVariant.Dot + }; + + private readonly BitFullCalendarTexts persianTexts = new() + { + AddEventButton = "افزودن رویداد", + AddEventDialogTitle = "افزودن رویداد جدید", + StartDateTimeLabel = "تاریخ و زمان شروع", + EndDateTimeLabel = "تاریخ و زمان پایان", + CreateEventButton = "ایجاد رویداد" + }; + + private readonly List resources = + [ + new() { Id = "room-bay", Title = "HQ - Bay Wing", Subtitle = "Headquarters" }, + new() { Id = "room-garden", Title = "The Garden", Subtitle = "Headquarters" }, + new() { Id = "room-war", Title = "War Room (B1)", Subtitle = "Basement" }, + ]; + + private readonly List resourceEvents = CreateResourceEvents(); + + private string? lastChange; + + private Task HandleChange(BitFullCalendarChangeEventArgs args) + { + lastChange = $"{args.Kind} ({args.Source}): {args.Event.Title}"; + return InvokeAsync(StateHasChanged); + } + + private static List CreateEvents() + { + var today = DateTime.Today; + var id = 0; + return + [ + new() { Id = (++id).ToString(), Title = "Team Standup", Description = "Daily sync with engineering.", StartDate = today.AddHours(9), EndDate = today.AddHours(9).AddMinutes(45), Color = "blue" }, + new() { Id = (++id).ToString(), Title = "Design Review", Description = "Dashboard mockups v2.", StartDate = today.AddHours(10), EndDate = today.AddHours(11), Color = "purple" }, + new() { Id = (++id).ToString(), Title = "1:1 with Manager", Description = "Career and sprint check-in.", StartDate = today.AddHours(10).AddMinutes(30), EndDate = today.AddHours(11).AddMinutes(15), Color = "yellow" }, + new() { Id = (++id).ToString(), Title = "Lunch with Client", Description = "Q3 roadmap discussion.", StartDate = today.AddHours(12), EndDate = today.AddHours(13).AddMinutes(30), Color = "green" }, + new() { Id = (++id).ToString(), Title = "Sprint Planning", Description = "Next sprint goals and capacity.", StartDate = today.AddHours(14), EndDate = today.AddHours(15).AddMinutes(30), Color = "orange" }, + new() { Id = (++id).ToString(), Title = "Code Review", Description = "Auth module PRs.", StartDate = today.AddHours(16), EndDate = today.AddHours(17), Color = "red" }, + new() { Id = (++id).ToString(), Title = "Tech Conference", Description = "Keynotes and workshops.", StartDate = today.AddDays(1).AddHours(9), EndDate = today.AddDays(3).AddHours(17), Color = "blue" }, + new() { Id = (++id).ToString(), Title = "Client Onboarding", Description = "Platform walkthrough.", StartDate = today.AddDays(1).AddHours(10), EndDate = today.AddDays(1).AddHours(11).AddMinutes(30), Color = "yellow" }, + new() { Id = (++id).ToString(), Title = "Architecture Review", Description = "Migration plan.", StartDate = today.AddDays(2).AddHours(14), EndDate = today.AddDays(2).AddHours(16), Color = "red" }, + new() { Id = (++id).ToString(), Title = "Company Retreat", Description = "Strategy and team building.", StartDate = today.AddDays(5), EndDate = today.AddDays(7).AddHours(16), Color = "purple" }, + new() { Id = (++id).ToString(), Title = "Quarterly Review", Description = "Company-wide QBR.", StartDate = today.AddDays(-3).AddHours(10), EndDate = today.AddDays(-3).AddHours(12), Color = "red" }, + new() { Id = (++id).ToString(), Title = "Product Demo", Description = "Stakeholder walkthrough.", StartDate = today.AddDays(-2).AddHours(14), EndDate = today.AddDays(-2).AddHours(15), Color = "orange" }, + ]; + } + + private static List CreateResourceEvents() + { + var today = DateTime.Today; + var id = 100; + return + [ + new() { Id = (++id).ToString(), Title = "Design Review", StartDate = today.AddHours(10), EndDate = today.AddHours(11), Resource = "room-bay", Color = "purple" }, + new() { Id = (++id).ToString(), Title = "Standup", StartDate = today.AddHours(9), EndDate = today.AddHours(9).AddMinutes(30), Resource = "room-garden", Color = "blue" }, + new() { Id = (++id).ToString(), Title = "Incident Bridge", StartDate = today.AddHours(13), EndDate = today.AddHours(15), Resource = "room-war", Color = "red" }, + new() { Id = (++id).ToString(), Title = "Workshop", StartDate = today.AddHours(14), EndDate = today.AddHours(16), Resource = "room-bay", Color = "orange" }, + ]; + } + + + + private readonly string example1RazorCode = @" + + +@code { + private readonly List events = [ + new() { Id = ""1"", Title = ""Team Standup"", StartDate = DateTime.Today.AddHours(9), EndDate = DateTime.Today.AddHours(9).AddMinutes(45), Color = ""blue"" }, + new() { Id = ""2"", Title = ""Design Review"", StartDate = DateTime.Today.AddHours(10), EndDate = DateTime.Today.AddHours(11), Color = ""purple"" }, + ]; +}"; + + private readonly string example2RazorCode = @" +"; + + private readonly string example3RazorCode = @" + + +@code { + private readonly BitFullCalendarOptions options = new() + { + Use24HourFormat = false, + StartOfDayHour = 7, + BadgeVariant = BitFullCalendarBadgeVariant.Dot + }; +}"; + + private readonly string example4RazorCode = @" + + +@code { + private RenderFragment EventCard => ev => + @
+ @ev.Title + @if (!string.IsNullOrWhiteSpace(ev.Description)) + { + @ev.Description + } +
; + + private RenderFragment MonthBadge => ev => @📌 @ev.Title; +}"; + + private readonly string example5RazorCode = @" + + +@code { + private readonly List resources = [ + new() { Id = ""room-bay"", Title = ""HQ - Bay Wing"", Subtitle = ""Headquarters"" }, + new() { Id = ""room-garden"", Title = ""The Garden"", Subtitle = ""Headquarters"" }, + new() { Id = ""room-war"", Title = ""War Room (B1)"", Subtitle = ""Basement"" }, + ]; + + private readonly List events = [ + new() { Id = ""1"", Title = ""Design Review"", StartDate = DateTime.Today.AddHours(10), EndDate = DateTime.Today.AddHours(11), Resource = ""room-bay"", Color = ""purple"" }, + ]; +}"; + + private readonly string example6RazorCode = @" + + +
Last change: @lastChange
+ +@code { + private string? lastChange; + + private Task HandleChange(BitFullCalendarChangeEventArgs args) + { + lastChange = $""{args.Kind} ({args.Source}): {args.Event.Title}""; + return Task.CompletedTask; + } +}"; + + private readonly string example7RazorCode = @" + + +@code { + private readonly BitFullCalendarTexts persianTexts = new() + { + AddEventButton = ""افزودن رویداد"", + AddEventDialogTitle = ""افزودن رویداد جدید"", + StartDateTimeLabel = ""تاریخ و زمان شروع"", + EndDateTimeLabel = ""تاریخ و زمان پایان"", + CreateEventButton = ""ایجاد رویداد"" + }; +}"; + + private readonly string example8RazorCode = @" +"; +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Home/ComponentsSection.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Home/ComponentsSection.razor index 1610817236..242376fa3a 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Home/ComponentsSection.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Home/ComponentsSection.razor @@ -274,6 +274,9 @@ Flag + + FullCalendar + InfiniteScrolling diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs index 5c61c3ebdf..bf23b6589e 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs @@ -160,6 +160,7 @@ public partial class MainLayout new() { Text = "DataGrid", Url = "/components/datagrid", AdditionalUrls = ["/components/data-grid"] }, new() { Text = "ErrorBoundary", Url = "/components/errorboundary" }, new() { Text = "Flag", Url = "/components/flag" }, + new() { Text = "FullCalendar", Url = "/components/fullcalendar", Description = "Calendar, Scheduler" }, new() { Text = "InfiniteScrolling", Url = "/components/infinitescrolling" }, new() { Text = "Map", Url = "/components/map" }, new() { Text = "MarkdownEditor", Url = "/components/markdowneditor", Description = "MdEditor" },