From 575c3c5675e7748baeb8dafaebc7e674039f9b53 Mon Sep 17 00:00:00 2001 From: Amatsugu Date: Thu, 29 Jan 2026 13:35:20 -0500 Subject: [PATCH] added zoom + date selector --- AZKiServer/Services/FileScannerService.cs | 2 +- client/Cargo.lock | 73 ++++- client/Cargo.toml | 2 + client/assets/dx-components-theme.css | 83 +++++ client/src/app.rs | 2 + client/src/components/calendar/component.rs | 152 +++++++++ client/src/components/calendar/mod.rs | 2 + client/src/components/calendar/style.css | 297 ++++++++++++++++++ .../src/components/date_picker/component.rs | 147 +++++++++ client/src/components/date_picker/mod.rs | 2 + client/src/components/date_picker/style.css | 79 +++++ client/src/components/mod.rs | 8 +- client/src/components/playback/player.rs | 46 ++- client/src/components/playback/timeline.rs | 12 +- .../{calendar.rs => playback_calendar.rs} | 2 +- client/src/components/popover/component.rs | 41 +++ client/src/components/popover/mod.rs | 2 + client/src/components/popover/style.css | 228 ++++++++++++++ client/src/components/slider/component.rs | 52 +++ client/src/components/slider/mod.rs | 2 + client/src/components/slider/style.css | 76 +++++ 21 files changed, 1279 insertions(+), 31 deletions(-) create mode 100644 client/assets/dx-components-theme.css create mode 100644 client/src/components/calendar/component.rs create mode 100644 client/src/components/calendar/mod.rs create mode 100644 client/src/components/calendar/style.css create mode 100644 client/src/components/date_picker/component.rs create mode 100644 client/src/components/date_picker/mod.rs create mode 100644 client/src/components/date_picker/style.css rename client/src/components/{calendar.rs => playback_calendar.rs} (52%) create mode 100644 client/src/components/popover/component.rs create mode 100644 client/src/components/popover/mod.rs create mode 100644 client/src/components/popover/style.css create mode 100644 client/src/components/slider/component.rs create mode 100644 client/src/components/slider/mod.rs create mode 100644 client/src/components/slider/style.css diff --git a/AZKiServer/Services/FileScannerService.cs b/AZKiServer/Services/FileScannerService.cs index e4eefaa..36c7fa1 100644 --- a/AZKiServer/Services/FileScannerService.cs +++ b/AZKiServer/Services/FileScannerService.cs @@ -59,7 +59,7 @@ public class FileScannerService(MediaService mediaService, IConfiguration config foreach (var chunk in files.Chunk(50)) { total += await ScanFileChunkAsync(path, chunk, existingFiles, cancellationToken); - logger.LogInformation("Added {updated} of {count}", total, files.Length); + logger.LogInformation("Added {updated} of {count} [{percentage}%]", total, files.Length, Math.Round(((float)total/ files.Length) * 100)); } } catch (Exception ex) diff --git a/client/Cargo.lock b/client/Cargo.lock index f586b8a..86b0f0e 100644 --- a/client/Cargo.lock +++ b/client/Cargo.lock @@ -170,11 +170,13 @@ version = "0.1.0" dependencies = [ "chrono", "dioxus", + "dioxus-primitives", "dotenv", "prost", "prost-types", "serde", "serde_repr", + "time", "tokio", "tonic", "tonic-prost", @@ -913,7 +915,7 @@ dependencies = [ "global-hotkey", "infer", "jni", - "lazy-js-bundle", + "lazy-js-bundle 0.7.3", "libc", "muda", "ndk", @@ -982,7 +984,7 @@ dependencies = [ "futures-channel", "futures-util", "generational-box", - "lazy-js-bundle", + "lazy-js-bundle 0.7.3", "serde", "serde_json", "tracing", @@ -1132,7 +1134,7 @@ dependencies = [ "futures-util", "generational-box", "keyboard-types", - "lazy-js-bundle", + "lazy-js-bundle 0.7.3", "rustversion", "serde", "serde_json", @@ -1162,7 +1164,7 @@ dependencies = [ "dioxus-core-types", "dioxus-html", "js-sys", - "lazy-js-bundle", + "lazy-js-bundle 0.7.3", "rustc-hash 2.1.1", "serde", "sledgehammer_bindgen", @@ -1184,6 +1186,19 @@ dependencies = [ "tracing-wasm", ] +[[package]] +name = "dioxus-primitives" +version = "0.0.1" +source = "git+https://github.com/DioxusLabs/components#7943bed2eb59ee43d713f935e0ba17989c02b992" +dependencies = [ + "dioxus", + "dioxus-sdk-time", + "lazy-js-bundle 0.6.2", + "num-integer", + "time", + "tracing", +] + [[package]] name = "dioxus-router" version = "0.7.3" @@ -1232,6 +1247,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dioxus-sdk-time" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80c25ae93a3f72e734873b97fbd09d9b1b6adff97205fb0ffd8543e3564fb78e" +dependencies = [ + "dioxus", + "futures", + "gloo-timers", + "tokio", +] + [[package]] name = "dioxus-signals" version = "0.7.3" @@ -1292,7 +1319,7 @@ dependencies = [ "generational-box", "gloo-timers", "js-sys", - "lazy-js-bundle", + "lazy-js-bundle 0.7.3", "rustc-hash 2.1.1", "send_wrapper", "serde", @@ -2241,9 +2268,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -2508,6 +2535,12 @@ dependencies = [ "selectors", ] +[[package]] +name = "lazy-js-bundle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49596223b9d9d4947a14a25c142a6e7d8ab3f27eb3ade269d238bb8b5c267e2" + [[package]] name = "lazy-js-bundle" version = "0.7.3" @@ -2931,9 +2964,18 @@ checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] [[package]] name = "num-traits" @@ -4611,12 +4653,13 @@ dependencies = [ [[package]] name = "time" -version = "0.3.45" +version = "0.3.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" dependencies = [ "deranged", "itoa", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -4626,15 +4669,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.25" +version = "0.2.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" dependencies = [ "num-conv", "time-core", diff --git a/client/Cargo.toml b/client/Cargo.toml index 8db5f5a..95d24a6 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -19,6 +19,8 @@ tonic-web-wasm-client = "0.8" web-sys = { version = "0.3.77", features = ["Storage", "Window"] } tokio = "1.49.0" tonic-prost = "0.14.2" +dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false } +time = {version = "0.3.46", features= ["wasm-bindgen"]} chrono = "0.4.43" [build-dependencies] diff --git a/client/assets/dx-components-theme.css b/client/assets/dx-components-theme.css new file mode 100644 index 0000000..0c97f0d --- /dev/null +++ b/client/assets/dx-components-theme.css @@ -0,0 +1,83 @@ +/* This file contains the global styles for the styled dioxus components. You only + * need to import this file once in your project root. + */ +@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"); + +body { + padding: 0; + margin: 0; + background-color: var(--primary-color); + color: var(--secondary-color-4); + font-family: Inter, sans-serif; + font-optical-sizing: auto; + font-style: normal; + font-weight: 400; +} + +@media (prefers-color-scheme: dark) { + :root { + --dark: initial; + --light: ; + } +} + +@media (prefers-color-scheme: light) { + :root { + --dark: ; + --light: initial; + } +} + +:root { + /* Primary colors */ + --primary-color: var(--dark, #000) var(--light, #fff); + --primary-color-1: var(--dark, #0e0e0e) var(--light, #fbfbfb); + --primary-color-2: var(--dark, #0a0a0a) var(--light, #fff); + --primary-color-3: var(--dark, #141313) var(--light, #f8f8f8); + --primary-color-4: var(--dark, #1a1a1a) var(--light, #f8f8f8); + --primary-color-5: var(--dark, #262626) var(--light, #f5f5f5); + --primary-color-6: var(--dark, #232323) var(--light, #e5e5e5); + --primary-color-7: var(--dark, #3e3e3e) var(--light, #b0b0b0); + + /* Secondary colors */ + --secondary-color: var(--dark, #fff) var(--light, #000); + --secondary-color-1: var(--dark, #fafafa) var(--light, #000); + --secondary-color-2: var(--dark, #e6e6e6) var(--light, #0d0d0d); + --secondary-color-3: var(--dark, #dcdcdc) var(--light, #2b2b2b); + --secondary-color-4: var(--dark, #d4d4d4) var(--light, #111); + --secondary-color-5: var(--dark, #a1a1a1) var(--light, #848484); + --secondary-color-6: var(--dark, #5d5d5d) var(--light, #d0d0d0); + + /* Highlight colors */ + --focused-border-color: var(--dark, #2b7fff) var(--light, #2b7fff); + --primary-success-color: var(--dark, #02271c) var(--light, #ecfdf5); + --secondary-success-color: var(--dark, #b6fae3) var(--light, #10b981); + --primary-warning-color: var(--dark, #342203) var(--light, #fffbeb); + --secondary-warning-color: var(--dark, #feeac7) var(--light, #f59e0b); + --primary-error-color: var(--dark, #a22e2e) var(--light, #dc2626); + --secondary-error-color: var(--dark, #9b1c1c) var(--light, #ef4444); + --contrast-error-color: var(--dark, var(--secondary-color-3)) + var(--light, var(--primary-color)); + --primary-info-color: var(--dark, var(--primary-color-5)) + var(--light, var(--primary-color)); + --secondary-info-color: var(--dark, var(--primary-color-7)) + var(--light, var(--secondary-color-3)); +} + +/* Modern browsers with `scrollbar-*` support */ +@supports (scrollbar-width: auto) { + :not(:hover) { + scrollbar-color: rgb(0 0 0 / 0%) rgb(0 0 0 / 0%); + } + + :hover { + scrollbar-color: var(--secondary-color-2) rgb(0 0 0 / 0%); + } +} + +/* Legacy browsers with `::-webkit-scrollbar-*` support */ +@supports selector(::-webkit-scrollbar) { + :root::-webkit-scrollbar-track { + background: transparent; + } +} diff --git a/client/src/app.rs b/client/src/app.rs index e9f6fe0..68415c7 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -4,12 +4,14 @@ use crate::route::Route; const FAVICON: Asset = asset!("/assets/favicon.ico"); const MAIN_CSS: Asset = asset!("/assets/styling/main.scss"); +const DX_COMPONENTS: Asset = asset!("/assets/dx-components-theme.css"); #[component] pub fn App() -> Element { rsx! { document::Link { rel: "icon", href: FAVICON } document::Link { rel: "stylesheet", href: MAIN_CSS } + document::Link { rel: "stylesheet", href: DX_COMPONENTS } document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" } document::Link { rel: "preconnect", href: "https://fonts.gstatic.com" } diff --git a/client/src/components/calendar/component.rs b/client/src/components/calendar/component.rs new file mode 100644 index 0000000..908540b --- /dev/null +++ b/client/src/components/calendar/component.rs @@ -0,0 +1,152 @@ +use dioxus::prelude::*; +use dioxus_primitives::calendar::{ + self, CalendarDayProps, CalendarGridProps, CalendarHeaderProps, CalendarMonthTitleProps, + CalendarNavigationProps, CalendarProps, CalendarSelectMonthProps, CalendarSelectYearProps, + RangeCalendarProps, +}; + +#[component] +pub fn Calendar(props: CalendarProps) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + calendar::Calendar { + class: "calendar", + selected_date: props.selected_date, + on_date_change: props.on_date_change, + on_format_weekday: props.on_format_weekday, + on_format_month: props.on_format_month, + view_date: props.view_date, + today: props.today, + on_view_change: props.on_view_change, + disabled: props.disabled, + first_day_of_week: props.first_day_of_week, + min_date: props.min_date, + max_date: props.max_date, + month_count: props.month_count, + disabled_ranges: props.disabled_ranges, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn RangeCalendar(props: RangeCalendarProps) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + calendar::RangeCalendar { + class: "calendar", + selected_range: props.selected_range, + on_range_change: props.on_range_change, + on_format_weekday: props.on_format_weekday, + on_format_month: props.on_format_month, + view_date: props.view_date, + today: props.today, + on_view_change: props.on_view_change, + disabled: props.disabled, + first_day_of_week: props.first_day_of_week, + min_date: props.min_date, + max_date: props.max_date, + month_count: props.month_count, + disabled_ranges: props.disabled_ranges, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn CalendarView( + #[props(extends = GlobalAttributes)] attributes: Vec, + children: Element, +) -> Element { + rsx! { + div { + class: "calendar-view", + ..attributes, + {children} + } + } +} + +#[component] +pub fn CalendarHeader(props: CalendarHeaderProps) -> Element { + rsx! { + calendar::CalendarHeader { id: props.id, attributes: props.attributes, {props.children} } + } +} + +#[component] +pub fn CalendarNavigation(props: CalendarNavigationProps) -> Element { + rsx! { + calendar::CalendarNavigation { attributes: props.attributes, {props.children} } + } +} + +#[component] +pub fn CalendarPreviousMonthButton( + #[props(extends = GlobalAttributes)] attributes: Vec, +) -> Element { + rsx! { + calendar::CalendarPreviousMonthButton { attributes, + svg { + class: "calendar-previous-month-icon", + view_box: "0 0 24 24", + xmlns: "http://www.w3.org/2000/svg", + polyline { points: "15 6 9 12 15 18" } + } + } + } +} + +#[component] +pub fn CalendarNextMonthButton( + #[props(extends = GlobalAttributes)] attributes: Vec, +) -> Element { + rsx! { + calendar::CalendarNextMonthButton { attributes, + svg { + class: "calendar-next-month-icon", + view_box: "0 0 24 24", + xmlns: "http://www.w3.org/2000/svg", + polyline { points: "9 18 15 12 9 6" } + } + } + } +} + +#[component] +pub fn CalendarSelectMonth(props: CalendarSelectMonthProps) -> Element { + rsx! { + calendar::CalendarSelectMonth { class: "calendar-month-select", attributes: props.attributes } + } +} + +#[component] +pub fn CalendarSelectYear(props: CalendarSelectYearProps) -> Element { + rsx! { + calendar::CalendarSelectYear { class: "calendar-year-select", attributes: props.attributes } + } +} + +#[component] +pub fn CalendarGrid(props: CalendarGridProps) -> Element { + rsx! { + calendar::CalendarGrid { + id: props.id, + show_week_numbers: props.show_week_numbers, + render_day: props.render_day, + attributes: props.attributes, + } + } +} + +#[component] +pub fn CalendarMonthTitle(props: CalendarMonthTitleProps) -> Element { + calendar::CalendarMonthTitle(props) +} + +#[component] +pub fn CalendarDay(props: CalendarDayProps) -> Element { + calendar::CalendarDay(props) +} diff --git a/client/src/components/calendar/mod.rs b/client/src/components/calendar/mod.rs new file mode 100644 index 0000000..1723764 --- /dev/null +++ b/client/src/components/calendar/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; \ No newline at end of file diff --git a/client/src/components/calendar/style.css b/client/src/components/calendar/style.css new file mode 100644 index 0000000..85e9799 --- /dev/null +++ b/client/src/components/calendar/style.css @@ -0,0 +1,297 @@ +/* Calendar Container */ +.calendar { + display: flex; + flex-direction: row; + border: 1px solid var(--primary-color-6); + border-radius: 8px; + background-color: var(--primary-color-2); + box-shadow: 0 2px 10px rgb(0 0 0 / 10%); + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, + sans-serif; +} + +/* Calendar Navigation */ +.calendar-navigation { + position: relative; + display: flex; + align-items: center; + justify-content: center; + padding: 0.75rem; + padding: 0.75rem calc(0.75rem + 1.75rem + 0.5rem) 0.25rem; + gap: 0.5rem; +} + +.calendar-nav-title { + color: var(--secondary-color-4); + font-size: 16px; + font-weight: 600; +} + +.calendar-nav-prev, +.calendar-nav-next { + position: absolute; + display: flex; + width: 1.75rem; + height: 1.75rem; + align-items: center; + justify-content: center; + border: 1px solid var(--primary-color-6); + border-radius: 0.5rem; + background-color: var(--light, transparent) + var(--dark, var(--primary-color-3)); + color: var(--secondary-color-5); + cursor: pointer; + font-size: 1rem; +} + +.calendar-nav-prev { + left: 0.75rem; +} + +.calendar-nav-next { + right: 0.75rem; +} + +.calendar-nav-prev:hover, +.calendar-nav-next:hover { + border-color: var(--primary-color-7); + background-color: var(--primary-color-4); + color: var(--secondary-color-4); +} + +.calendar-nav-prev:focus-visible, +.calendar-nav-next:focus-visible { + box-shadow: 0 0 0 2px var(--focused-border-color); +} + +.calendar-nav-prev:disabled, +.calendar-nav-next:disabled { + border-color: var(--primary-color-5); + background-color: var(--primary-color-2); + color: var(--secondary-color-3); + cursor: not-allowed; +} + +.calendar-month-title { + display: flex; + width: 100%; + height: 1.75rem; + align-items: center; + justify-content: center; +} + +/* Calendar Grid */ +.calendar-view { + display: flex; + flex-direction: column; +} + +.calendar-grid { + width: 100%; + padding: 0.5rem; +} + +.calendar-grid-header { + display: flex; + flex-direction: row; + margin-bottom: 8px; +} + +.calendar-grid-day-header { + flex: 1; + color: var(--secondary-color-5); + font-size: 12px; + font-weight: 300; + text-align: center; +} + +.calendar-grid-body { + display: flex; + width: 100%; + flex-direction: column; + gap: 0.25rem; +} + +.calendar-grid-cell { + width: 2rem; + border: none; + border-radius: 0.5rem; + aspect-ratio: 1; + background: none; + color: var(--secondary-color-4); + cursor: pointer; + font-size: 14px; +} + +.calendar-grid-cell[data-month="current"]:not([data-disabled="true"]):hover { + background-color: var(--primary-color-4); +} + +.calendar-grid-cell[data-month="current"]:focus-visible { + outline: 2px solid var(--focused-border-color); + outline-offset: 2px; +} + +.calendar-grid-cell[data-month="last"], +.calendar-grid-cell[data-month="next"], +.calendar-grid-cell[data-disabled="true"] { + color: var(--secondary-color-5); + cursor: not-allowed; +} + +.calendar-grid-cell[data-month="last"][data-selected="true"], +.calendar-grid-cell[data-month="next"][data-selected="true"] { + background-color: var(--secondary-color-6); +} + +.calendar-grid-cell[data-month="current"][data-selected="true"] { + background-color: var(--secondary-color-2); + color: var(--primary-color); +} + +.calendar-grid-cell[data-month="current"][data-unavailable="true"] { + color: var(--secondary-color-6); + cursor: not-allowed; + text-decoration: line-through; +} + +.calendar-grid-week td { + padding-right: 0; + padding-left: 0; +} + +.calendar-grid-week td:first-child .calendar-grid-cell { + border-bottom-left-radius: 0.5rem; + border-top-left-radius: 0.5rem; +} + +.calendar-grid-week td:last-child .calendar-grid-cell { + border-bottom-right-radius: 0.5rem; + border-top-right-radius: 0.5rem; +} + +.calendar-grid-cell[data-month="last"][data-selection-between="true"], +.calendar-grid-cell[data-month="next"][data-selection-between="true"] { + border-radius: 0; + background-color: var(--primary-color-5); + color: var(--secondary-color-5); +} + +.calendar-grid-cell[data-month="current"][data-selection-between="true"] { + border-radius: 0; + background-color: var(--primary-color-5); + color: var(--secondary-color-4); +} + +td:has(.calendar-grid-cell[data-selection-start="true"]) { + padding: 0; + margin-top: 1px; + margin-bottom: 1px; + background-color: var(--primary-color-5); + border-bottom-left-radius: 0.5rem; + border-top-left-radius: 0.5rem; +} + +td:has(.calendar-grid-cell[data-selection-end="true"]) { + padding: 0; + margin-top: 1px; + margin-bottom: 1px; + background-color: var(--primary-color-5); + border-bottom-right-radius: 0.5rem; + border-top-right-radius: 0.5rem; +} + +.calendar-grid-cell[data-month="current"][data-selected="true"]:hover { + background-color: var(--light, var(--secondary-color-2)) + var(--dark, var(--primary-color-5)); + color: var(--light, var(--primary-color)) + var(--dark, var(--secondary-color-1)); + font-weight: var(--light, 550) var(--dark, inherit); +} + +.calendar-grid-cell[data-month="current"][data-today="true"]:not( + [data-selected="true"] + ) { + background-color: var(--primary-color-5); +} + +.calendar-grid-weeknum { + border-radius: 0.5rem; + background-color: var(--primary-color); + color: var(--secondary-color-5); + font-size: 12px; +} + +/* Calendar with week numbers */ +.calendar-grid-week { + display: flex; + width: 100%; + flex-direction: row; + justify-content: space-between; +} + +/* Calendar states */ +.calendar[data-disabled="true"] { + opacity: 0.6; + pointer-events: none; +} + +.calendar-next-month-icon, +.calendar-previous-month-icon { + width: 20px; + height: 20px; + fill: none; + stroke: currentcolor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + +.calendar-month-select-container, +.calendar-year-select-container { + position: relative; +} + +.calendar-month-select-container:has(:focus-visible), +.calendar-year-select-container:has(:focus-visible) { + border-radius: 0.5rem; + outline: 2px solid var(--focused-border-color); +} + +.calendar-month-select, +.calendar-year-select { + position: absolute; + width: 100%; + height: 100%; + padding: 0.25rem; + margin: 0; + inset: 0; + opacity: 0; +} + +.calendar-month-select-value, +.calendar-year-select-value { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + border: none; + background-color: transparent; + color: var(--secondary-color-4); + cursor: pointer; + font-size: 1rem; + transition: + background-color 0.2s ease, + color 0.2s ease; +} + +.select-expand-icon { + width: 20px; + height: 20px; + fill: none; + stroke: var(--secondary-color-4); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} \ No newline at end of file diff --git a/client/src/components/date_picker/component.rs b/client/src/components/date_picker/component.rs new file mode 100644 index 0000000..fdc4406 --- /dev/null +++ b/client/src/components/date_picker/component.rs @@ -0,0 +1,147 @@ +use dioxus::prelude::*; + +use dioxus_primitives::{ + date_picker::{self, DatePickerInputProps, DatePickerProps, DateRangePickerProps}, + popover::{PopoverContentProps, PopoverTriggerProps}, + ContentAlign, +}; + +use super::super::calendar::*; +use super::super::popover::*; + +#[component] +pub fn DatePicker(props: DatePickerProps) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + div { + date_picker::DatePicker { + class: "date-picker", + on_value_change: props.on_value_change, + selected_date: props.selected_date, + disabled: props.disabled, + read_only: props.read_only, + min_date: props.min_date, + max_date: props.max_date, + month_count: props.month_count, + disabled_ranges: props.disabled_ranges, + roving_loop: props.roving_loop, + attributes: props.attributes, + date_picker::DatePickerPopover { + popover_root: PopoverRoot, + {props.children} + } + } + } + } +} + +#[component] +pub fn DateRangePicker(props: DateRangePickerProps) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + div { + date_picker::DateRangePicker { + class: "date-picker", + on_range_change: props.on_range_change, + selected_range: props.selected_range, + disabled: props.disabled, + read_only: props.read_only, + min_date: props.min_date, + max_date: props.max_date, + month_count: props.month_count, + disabled_ranges: props.disabled_ranges, + roving_loop: props.roving_loop, + attributes: props.attributes, + date_picker::DatePickerPopover { popover_root: PopoverRoot, {props.children} } + } + } + } +} + +#[component] +pub fn DatePickerInput(props: DatePickerInputProps) -> Element { + rsx! { + date_picker::DatePickerInput { + on_format_day_placeholder: props.on_format_day_placeholder, + on_format_month_placeholder: props.on_format_month_placeholder, + on_format_year_placeholder: props.on_format_year_placeholder, + attributes: props.attributes, + {props.children} + DatePickerPopoverTrigger {} + DatePickerPopoverContent { align: ContentAlign::Center, + date_picker::DatePickerCalendar { calendar: Calendar, + CalendarView { + CalendarHeader { + CalendarNavigation { + CalendarPreviousMonthButton {} + CalendarSelectMonth {} + CalendarSelectYear {} + CalendarNextMonthButton {} + } + } + CalendarGrid {} + } + } + } + } + } +} + +#[component] +pub fn DateRangePickerInput(props: DatePickerInputProps) -> Element { + rsx! { + date_picker::DateRangePickerInput { + on_format_day_placeholder: props.on_format_day_placeholder, + on_format_month_placeholder: props.on_format_month_placeholder, + on_format_year_placeholder: props.on_format_year_placeholder, + attributes: props.attributes, + {props.children} + DatePickerPopoverTrigger {} + DatePickerPopoverContent { + align: ContentAlign::Center, + date_picker::DateRangePickerCalendar { + calendar: RangeCalendar, + CalendarView { + CalendarHeader { + CalendarNavigation { + CalendarPreviousMonthButton {} + CalendarSelectMonth {} + CalendarSelectYear {} + CalendarNextMonthButton {} + } + } + CalendarGrid {} + } + } + } + } + } +} + +#[component] +pub fn DatePickerPopoverTrigger(props: PopoverTriggerProps) -> Element { + rsx! { + PopoverTrigger { aria_label: "Show Calendar", attributes: props.attributes, + svg { + class: "date-picker-expand-icon", + view_box: "0 0 24 24", + xmlns: "http://www.w3.org/2000/svg", + polyline { points: "6 9 12 15 18 9" } + } + } + } +} + +#[component] +pub fn DatePickerPopoverContent(props: PopoverContentProps) -> Element { + rsx! { + PopoverContent { + class: "popover-content", + id: props.id, + side: props.side, + align: props.align, + attributes: props.attributes, + {props.children} + } + } +} diff --git a/client/src/components/date_picker/mod.rs b/client/src/components/date_picker/mod.rs new file mode 100644 index 0000000..1723764 --- /dev/null +++ b/client/src/components/date_picker/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; \ No newline at end of file diff --git a/client/src/components/date_picker/style.css b/client/src/components/date_picker/style.css new file mode 100644 index 0000000..4202940 --- /dev/null +++ b/client/src/components/date_picker/style.css @@ -0,0 +1,79 @@ +.date-picker { + position: relative; + display: inline-flex; + align-items: center; +} + +.date-picker-group .popover-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background-color: transparent; + cursor: pointer; + transition: rotate 150ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.popover[data-state="open"] div .date-picker-trigger { + rotate: 180deg; +} + +.date-picker-expand-icon { + width: 20px; + height: 20px; + fill: none; + stroke: var(--primary-color-7); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + +[data-disabled="true"] { + cursor: not-allowed; + opacity: 0.5; +} + +.date-picker-group { + display: flex; + width: fit-content; + min-width: 150px; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 0.5rem; + border: none; + border-radius: 0.5rem; + background: none; + background: var(--light, var(--primary-color)) + var(--dark, var(--primary-color-3)); + box-shadow: inset 0 0 0 1px var(--light, var(--primary-color-6)) + var(--dark, var(--primary-color-7)); + color: var(--secondary-color-4); + gap: 0.25rem; + transition: background-color 100ms ease-out; +} + +.date-picker-group .popover-content { + max-width: unset; + padding: 0; +} + +.date-segment { + caret-color: transparent; +} + +.date-segment[no-date="true"] { + color: var(--secondary-color-5); +} + +.date-segment[is-separator="true"] { + padding: 0; +} + +.date-segment:focus-visible { + border-radius: 0.25rem; + background: var(--secondary-color-3); + color: var(--primary-color); + outline: none; +} diff --git a/client/src/components/mod.rs b/client/src/components/mod.rs index 8f05718..13fce9c 100644 --- a/client/src/components/mod.rs +++ b/client/src/components/mod.rs @@ -1,3 +1,7 @@ -mod calendar; +pub mod calendar; pub mod playback; -pub use calendar::*; +mod playback_calendar; +pub use playback_calendar::*; +pub mod date_picker; +pub mod popover; +pub mod slider; diff --git a/client/src/components/playback/player.rs b/client/src/components/playback/player.rs index 876e875..f88c4df 100644 --- a/client/src/components/playback/player.rs +++ b/client/src/components/playback/player.rs @@ -1,19 +1,31 @@ use chrono::{Datelike, Local}; use dioxus::prelude::*; +use dioxus_primitives::slider::{SliderTrack, SliderValue}; use prost_types::Timestamp; +use time::Date; use crate::{ - components::playback::{Timeline, Viewport}, + components::{ + date_picker::{DatePicker, DatePickerInput}, + playback::{Timeline, Viewport}, + slider::{Slider, SliderRange, SliderThumb}, + }, rpc::{azki::MediaPlaybackRequest, get_rpc_client}, }; const PLAYER_CSS: Asset = asset!("/assets/styling/player.scss"); #[component] pub fn Player() -> Element { - let playbackResult = use_resource(|| async move { - let mut client = get_rpc_client(); + let mut selected_date = use_signal(|| { let now = Local::now(); - let from = Timestamp::date(now.year() as i64, now.month() as u8, now.day() as u8 - 4).unwrap(); + Some(Date::from_ordinal_date(now.year(), now.ordinal() as u16).unwrap()) + }); + let mut zoom = use_signal(|| 1.0 as f32); + let playbackResult = use_resource(use_reactive!(|(selected_date)| async move { + let mut client = get_rpc_client(); + info!("Load data"); + let now = selected_date.cloned().unwrap(); + let from = Timestamp::date(now.year() as i64, now.month() as u8, now.day()).unwrap(); let result = client .get_media_playback(MediaPlaybackRequest { date: Some(from) }) .await; @@ -25,7 +37,7 @@ pub fn Player() -> Element { let msg = err.message(); return Err(format!("Failed to load results: {msg}")); } - }); + })); let info = match playbackResult.cloned() { Some(value) => match value { Ok(result) => Some(result), @@ -39,9 +51,31 @@ pub fn Player() -> Element { id: "player", div { id: "head", + Slider{ + value: SliderValue::Single(zoom() as f64), + horizontal: true, + min: 1.0, + max: 5.0, + step: 0.1, + on_value_change: move |val| { + let SliderValue::Single(v) = val; + zoom.set(v as f32); + }, + SliderTrack{ + SliderRange{} + SliderThumb{} + } + }, + DatePicker{ + selected_date, + on_value_change: move |v| { + selected_date.set(v); + }, + DatePickerInput{} + } } Viewport { } - Timeline { playbackInfo: info } + Timeline { playbackInfo: info, zoom } } } } diff --git a/client/src/components/playback/timeline.rs b/client/src/components/playback/timeline.rs index 2f0a345..7c693f1 100644 --- a/client/src/components/playback/timeline.rs +++ b/client/src/components/playback/timeline.rs @@ -3,25 +3,25 @@ use dioxus::prelude::*; use crate::rpc::azki::{MediaChannel, MediaEntry, PlaybackInfo}; #[component] -pub fn Timeline(playbackInfo: Option) -> Element { +pub fn Timeline(playbackInfo: Option, zoom: Signal) -> Element { return match playbackInfo { Some(info) => rsx! { div{ id: "timeline", - TrackList { channels: info.channels, start: info.date.unwrap().seconds, zoom: 2.0 } + TrackList { channels: info.channels, start: info.date.unwrap().seconds, zoom } } }, None => rsx! { div{ id: "timeline", - TrackList { channels: Vec::new(), start: 0, zoom: 1.0 } + TrackList { channels: Vec::new(), start: 0, zoom } } }, }; } #[component] -fn TrackList(channels: Vec, start: i64, zoom: f32) -> Element { +fn TrackList(channels: Vec, start: i64, zoom: Signal) -> Element { rsx! { div { id: "tracklist", @@ -33,11 +33,11 @@ fn TrackList(channels: Vec, start: i64, zoom: f32) -> Element { } #[component] -fn TimelineTrack(channel: MediaChannel, start: i64, zoom: f32) -> Element { +fn TimelineTrack(channel: MediaChannel, start: i64, zoom: Signal) -> Element { rsx! { div{ class: "track", - style: "width: calc({zoom * 100.0}% - 100px);", + style: "width: calc({zoom() * 100.0}% - 100px);", TrackLabel { channel: channel.clone() }, {channel.videos.iter().map(|m|rsx!{Clip{media: m.clone(), start}})} } diff --git a/client/src/components/calendar.rs b/client/src/components/playback_calendar.rs similarity index 52% rename from client/src/components/calendar.rs rename to client/src/components/playback_calendar.rs index 5963f68..439f0ec 100644 --- a/client/src/components/calendar.rs +++ b/client/src/components/playback_calendar.rs @@ -1,6 +1,6 @@ use dioxus::prelude::*; #[component] -pub fn Calendar() -> Element { +pub fn PlaybackCalendar() -> Element { rsx! {} } diff --git a/client/src/components/popover/component.rs b/client/src/components/popover/component.rs new file mode 100644 index 0000000..f921501 --- /dev/null +++ b/client/src/components/popover/component.rs @@ -0,0 +1,41 @@ +use dioxus::prelude::*; +use dioxus_primitives::popover::{ + self, PopoverContentProps, PopoverRootProps, PopoverTriggerProps, +}; + +#[component] +pub fn PopoverRoot(props: PopoverRootProps) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + popover::PopoverRoot { + class: "popover", + is_modal: props.is_modal, + open: props.open, + default_open: props.default_open, + on_open_change: props.on_open_change, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn PopoverTrigger(props: PopoverTriggerProps) -> Element { + rsx! { + popover::PopoverTrigger { class: "popover-trigger", attributes: props.attributes, {props.children} } + } +} + +#[component] +pub fn PopoverContent(props: PopoverContentProps) -> Element { + rsx! { + popover::PopoverContent { + class: "popover-content", + id: props.id, + side: props.side, + align: props.align, + attributes: props.attributes, + {props.children} + } + } +} diff --git a/client/src/components/popover/mod.rs b/client/src/components/popover/mod.rs new file mode 100644 index 0000000..1723764 --- /dev/null +++ b/client/src/components/popover/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; \ No newline at end of file diff --git a/client/src/components/popover/style.css b/client/src/components/popover/style.css new file mode 100644 index 0000000..e77f0f7 --- /dev/null +++ b/client/src/components/popover/style.css @@ -0,0 +1,228 @@ +.popover { + position: relative; + display: inline-block; +} + +.popover-content { + position: fixed; + z-index: 1000; + display: flex; + min-width: 200px; + max-width: calc(100% - 2rem); + box-sizing: border-box; + flex-direction: column; + padding: .25rem; + border-radius: .5rem; + margin-top: .5rem; + background: var(--light, var(--primary-color)) + var(--dark, var(--primary-color-5)); + box-shadow: inset 0 0 0 1px var(--light, var(--primary-color-6)) var(--dark, var(--primary-color-7)); + text-align: center; + transform: translate(-50%, -50%); + transform-origin: top; + will-change: transform, opacity; +} + +.popover-content[data-state="closed"] { + display: none; +} + +.popover-content[data-state="open"] { + display: flex; + animation: popover-fade-in .2s ease-in-out; +} + +@keyframes popover-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +/* Positioning based on side */ +.popover-content[data-side="top"] { + position: absolute; + bottom: 100%; + left: 50%; + margin-bottom: 8px; + transform: translateX(-50%); +} + +.popover-content[data-side="top"]::after { + top: calc(100% - 0.25rem); + left: 50%; + border-color: var(--secondary-color-4); + border-radius: 0 0 0.1rem; +} + +.popover-content[data-side="right"] { + position: absolute; + top: 50%; + left: 100%; + margin-left: 8px; + transform: translateY(-50%); +} + +.popover-content[data-side="right"]::after { + top: calc(50% - 0.25rem); + left: 0; + border-color: var(--secondary-color-4); + border-radius: 0 0 0 0.1rem; +} + +.popover-content[data-side="bottom"] { + position: absolute; + top: 100%; + left: 50%; + margin-top: 8px; + transform: translateX(-50%); +} + +.popover-content[data-side="bottom"]::after { + bottom: calc(100% - 0.25rem); + left: 50%; + border-color: var(--secondary-color-4); + border-radius: 0.1rem 0 0; +} + +.popover-content[data-side="left"] { + position: absolute; + top: 50%; + right: 100%; + margin-right: 8px; + transform: translateY(-50%); +} + +.popover-content[data-side="left"]::after { + top: calc(50% - 0.25rem); + right: -0.25rem; + border-color: var(--secondary-color-4); + border-radius: 0 0.1rem 0 0; +} + +/* Alignment styles for top and bottom */ +.popover-content[data-side="top"][data-align="start"], +.popover-content[data-side="bottom"][data-align="start"] { + left: 0; + transform: none; +} + +.popover-content[data-side="top"][data-align="end"], +.popover-content[data-side="bottom"][data-align="end"] { + right: 0; + left: auto; + transform: none; +} + +/* Alignment styles for left and right */ +.popover-content[data-side="left"][data-align="start"], +.popover-content[data-side="right"][data-align="start"] { + top: 0; + transform: none; +} + +.popover-content[data-side="left"][data-align="center"], +.popover-content[data-side="right"][data-align="center"] { + top: 50%; + transform: translateY(-50%); +} + +.popover-content[data-side="left"][data-align="end"], +.popover-content[data-side="right"][data-align="end"] { + top: auto; + bottom: 0; + transform: none; +} + +.popover-content-title { + margin: 0; + color: var(--secondary-color-4); + font-size: 1.25rem; + font-weight: 700; +} + +.popover-content-description { + margin: 0; + color: var(--secondary-color-5); + font-size: 1rem; +} + +.popover-content-actions { + display: flex; + flex-direction: column-reverse; + gap: 12px; +} + +@media (width >= 40rem) { + .popover-content-actions { + flex-direction: row; + justify-content: flex-end; + } + + .popover-content { + max-width: 32rem; + text-align: left; + } +} + +.popover-content-cancel { + padding: 8px 18px; + border: 1px solid var(--primary-color-6); + border-radius: 0.5rem; + background-color: var(--light, var(--primary-color)) + var(--dark, var(--primary-color-3)); + color: var(--secondary-color-4); + cursor: pointer; + font-size: 1rem; + transition: background-color 0.2s ease; +} + +.popover-content-cancel:hover { + background-color: var(--primary-color-4); +} + +.popover-content-cancel:focus-visible { + box-shadow: 0 0 0 2px var(--focused-border-color); +} + +.popover-content-action { + padding: 8px 18px; + border: 1px solid var(--primary-error-color); + border-radius: 0.5rem; + background-color: var(--primary-error-color); + color: var(--contrast-error-color); + cursor: pointer; + font-size: 1rem; + transition: background-color 0.2s ease; +} + +.popover-content-action:hover { + background-color: var(--secondary-error-color); +} + +.popover-content-action:focus-visible { + box-shadow: 0 0 0 2px var(--focused-border-color); +} + +.popover-trigger { + padding: 8px 18px; + border: 1px solid var(--primary-color-6); + border-radius: 0.5rem; + background-color: var(--light, var(--primary-color)) + var(--dark, var(--primary-color-3)); + color: var(--secondary-color-4); + cursor: pointer; + font-size: 1rem; + transition: background-color 0.2s ease; +} + +.popover-trigger:hover { + background-color: var(--primary-color-4); +} + +.popover-trigger:focus-visible { + box-shadow: 0 0 0 2px var(--focused-border-color); +} diff --git a/client/src/components/slider/component.rs b/client/src/components/slider/component.rs new file mode 100644 index 0000000..f49165c --- /dev/null +++ b/client/src/components/slider/component.rs @@ -0,0 +1,52 @@ +use dioxus::prelude::*; +use dioxus_primitives::slider::{ + self, SliderProps, SliderRangeProps, SliderThumbProps, SliderTrackProps, +}; + +#[component] +pub fn Slider(props: SliderProps) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + slider::Slider { + class: "slider", + value: props.value, + default_value: props.default_value, + min: props.min, + max: props.max, + step: props.step, + disabled: props.disabled, + horizontal: props.horizontal, + inverted: props.inverted, + on_value_change: props.on_value_change, + label: props.label, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn SliderTrack(props: SliderTrackProps) -> Element { + rsx! { + slider::SliderTrack { class: "slider-track", attributes: props.attributes, {props.children} } + } +} + +#[component] +pub fn SliderRange(props: SliderRangeProps) -> Element { + rsx! { + slider::SliderRange { class: "slider-range", attributes: props.attributes, {props.children} } + } +} + +#[component] +pub fn SliderThumb(props: SliderThumbProps) -> Element { + rsx! { + slider::SliderThumb { + class: "slider-thumb", + index: props.index, + attributes: props.attributes, + {props.children} + } + } +} diff --git a/client/src/components/slider/mod.rs b/client/src/components/slider/mod.rs new file mode 100644 index 0000000..1723764 --- /dev/null +++ b/client/src/components/slider/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; \ No newline at end of file diff --git a/client/src/components/slider/style.css b/client/src/components/slider/style.css new file mode 100644 index 0000000..30264da --- /dev/null +++ b/client/src/components/slider/style.css @@ -0,0 +1,76 @@ +.slider { + position: relative; + display: flex; + width: 200px; + align-items: center; + padding: 0.5rem 0; + touch-action: none; +} + +.slider[data-orientation="vertical"] { + width: auto; + height: 200px; + flex-direction: column; +} + +.slider-track { + position: relative; + height: 0.5rem; + box-sizing: border-box; + flex-grow: 1; + border-radius: 9999px; + background: var(--primary-color-5); +} + +.slider[data-orientation="vertical"] .slider-track { + width: 4px; + height: 100%; +} + +.slider-range { + position: absolute; + height: 100%; + border-radius: 9999px; + background-color: var(--secondary-color-2); +} + +.slider[data-orientation="vertical"] .slider-range { + width: 100%; +} + +.slider-thumb { + all: unset; + position: absolute; + top: 50%; + display: block; + width: 16px; + height: 16px; + border: 1px solid var(--secondary-color-2); + border-radius: 50%; + background-color: var(--primary-color-1); + cursor: pointer; + transform: translate(-50%, -50%); + transition: border-color 150ms; +} + +.slider[data-orientation="vertical"] .slider-thumb { + left: 50%; + transform: translate(-50%, 50%); +} + +.slider-thumb:focus-visible[data-dragging="true"], +.slider-thumb:focus-visible, +.slider-thumb:hover { + box-shadow: 0 0 0 4px + color-mix(in oklab, var(--primary-color-7) 50%, transparent); + transition: box-shadow 150ms; +} + +.slider[data-disabled="true"] { + cursor: not-allowed; + opacity: 0.5; +} + +.slider[data-disabled="true"] .slider-thumb { + cursor: not-allowed; +}