diff --git a/components/analytics/AnalyticsModal.module.css b/components/analytics/AnalyticsModal.module.css index 3925c354..25282df7 100644 --- a/components/analytics/AnalyticsModal.module.css +++ b/components/analytics/AnalyticsModal.module.css @@ -10,118 +10,106 @@ z-index: 1000; } -/* ── Modal shell ─────────────────────────────────────────── */ +/* ── Modal shell ───────────────────────────────────────────── + Same box as the dashboard modal (see DashboardModal.module.css): sized off the + viewport on both axes so the two modals keep the same footprint through a + rotation and read as one family. */ .modal { - width: calc(100vw - 80px); - max-width: 1200px; - height: calc(100vh - 80px); - max-height: 900px; + width: min(1100px, 92vw); + height: min(840px, 90vh); display: flex; border-radius: 16px; overflow: hidden; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); } -/* ── Sidebar ─────────────────────────────────────────────── */ -.sidebar { +/* ── Content area ──────────────────────────────────────────── + The single pane: there is no nav sidebar, every section is stacked in the + scroll area below the header. */ +.content { flex: 1; - background: var(--editor-sidebar); - padding: 32px 0; - border-right: 1px solid var(--separator); display: flex; flex-direction: column; + padding: 30px; + background: var(--main-bg); + min-height: 0; + min-width: 0; } -.sidebarTitle { - font-size: 1.2rem; - padding: 0 24px; - margin-bottom: 18px; - color: var(--primary-text); -} - -.groupLabel { - font-family: var(--font-inter); - font-size: 0.7rem; - text-transform: uppercase; - color: var(--secondary-text); - margin-bottom: 5px; - margin-top: 14px; - padding: 0 12px; - opacity: 0.6; -} - -.navMenu { - padding: 0 12px; +.contentHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + flex-shrink: 0; } -.navItem { +.title { display: flex; align-items: center; - width: 100%; - padding: 8px 12px; - gap: 12px; + gap: 8px; + color: var(--primary-text); +} - background: transparent; - border: none; - border-radius: 8px; +.title svg { color: var(--secondary-text); - font-size: 0.9rem; - cursor: pointer; - transition: all 0.2s ease; } -.navItem.active { - background: var(--editor-tab-active); - color: var(--primary-text); - font-weight: 600; +.scrollArea { + overflow-y: auto; + flex: 1; + min-height: 0; + scrollbar-gutter: stable; + padding-right: 20px; } -.iconWrapper { +/* Same size/hover as the dashboard's close button. */ +.close_btn { display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + width: 36px; + height: 36px; + border: none; + border-radius: 10px; + background: none; + color: var(--secondary-text); + cursor: pointer; } -.iconWrapper.active svg { +.close_btn:hover { + background-color: var(--secondary-hover); color: var(--primary-text); } -.iconWrapper svg { - color: var(--secondary-text); +/* ── Sections ──────────────────────────────────────────────── + All the analytics coexist in one scroll; the title is the only separator, with + a rule between consecutive sections to group each title with its own cards. */ +.section + .section { + margin-top: 32px; + padding-top: 28px; + border-top: 1px solid var(--separator); } -/* Content Area (2/3 Width) */ -.content { - flex: 2; +.sectionTitle { display: flex; - flex-direction: column; - padding: 30px; - background: var(--main-bg); + align-items: center; + gap: 8px; + margin-bottom: 14px; + font-size: 1rem; + font-weight: 600; + color: var(--primary-text); } -.contentHeader { +.iconWrapper { display: flex; - justify-content: space-between; align-items: center; - margin-bottom: 20px; -} - -.scrollArea { - overflow-y: auto; - flex: 1; - scrollbar-gutter: stable; - padding-right: 20px; + justify-content: center; } -.close_btn { - height: 22px; - width: 22px; +.iconWrapper svg { color: var(--secondary-text); - cursor: pointer; -} - -.close_btn:hover { - color: var(--primary-text); } /* ── Stats page layout ───────────────────────────────────── */ @@ -190,13 +178,15 @@ padding: 40px 0; } -/* ── Reports placeholder ────────────────────────────────── */ +/* ── Reports placeholder ──────────────────────────────────── + Padded rather than `height: 100%`: it is now one block in a stacked scroll, + not a pane filling the modal on its own. */ .comingSoon { display: flex; flex-direction: column; align-items: center; justify-content: center; - height: 100%; + padding: 40px 0; gap: 12px; color: var(--secondary-text); opacity: 0.5; @@ -214,13 +204,11 @@ /* ── Phone ──────────────────────────────────────────────────── A dedicated layout (rendered via a JS fork on phone viewports — see AnalyticsModal) that matches the other navbar tool sheets: a full-width - panel pinned below the navbar with a compact header, a section dropdown, - and a scrollable body. These classes are only mounted on phone, so no - media query is needed to gate them. */ + panel pinned below the navbar with a compact header and a scrollable body. + These classes are only mounted on phone, so no media query is needed to gate + them. */ -/* Transparent full-screen catcher for outside taps. Its z-index sits below - the portaled dropdown menu (.portal_menu, z-index 300) so the section - picker's menu renders above the sheet rather than behind it. */ +/* Transparent full-screen catcher for outside taps. */ .mobileOverlay { position: fixed; inset: 0; @@ -283,34 +271,6 @@ color: var(--primary-text); } -/* Section picker row. */ -.mobileToolbar { - padding: 12px 16px; - border-bottom: 1px solid var(--separator); - flex-shrink: 0; -} - -/* Constrains the dropdown so it doesn't stretch the full panel width. */ -.mobileTabField { - width: 200px; - max-width: 100%; -} - -.tabSelectTrigger { - padding: 8px 12px; - background: var(--primary); - border: 1px solid var(--separator); - border-radius: 8px; - color: var(--primary-text); - font-size: 0.9rem; -} - -.tabOption { - display: flex; - align-items: center; - gap: 12px; -} - /* Scrollable body — padded on the right so content clears the scrollbar. */ .mobileScroll { flex: 1; diff --git a/components/analytics/AnalyticsModal.tsx b/components/analytics/AnalyticsModal.tsx index d7198b88..26c95f6b 100644 --- a/components/analytics/AnalyticsModal.tsx +++ b/components/analytics/AnalyticsModal.tsx @@ -1,11 +1,10 @@ "use client"; -import { useEffect, useState, ReactNode } from "react"; +import { useEffect, useState, ComponentType, ReactNode } from "react"; import { createPortal } from "react-dom"; import { BarChart2, Clapperboard, Users, MapPin, FileBarChart, X } from "lucide-react"; import { useIsPhone } from "@src/lib/utils/hooks"; -import Dropdown, { DropdownOption } from "@components/utils/Dropdown"; import ScenesStats from "./stats/ScenesStats"; import CharactersStats from "./stats/CharactersStats"; @@ -13,62 +12,6 @@ import LocationsStats from "./stats/LocationsStats"; import styles from "./AnalyticsModal.module.css"; -// ── Types ───────────────────────────────────────────────────────────────────── - -type AnalyticsTab = "scenes" | "characters" | "locations" | "reports"; - -interface MenuItem { - id: AnalyticsTab; - label: string; - icon: ReactNode; -} - -interface MenuSection { - group: string; - items: MenuItem[]; -} - -// ── Sidebar data ────────────────────────────────────────────────────────────── - -const MENU: MenuSection[] = [ - { - group: "Statistics", - items: [ - { id: "scenes", label: "Scenes", icon: }, - { id: "characters", label: "Characters", icon: }, - { id: "locations", label: "Locations", icon: }, - ], - }, - { - group: "Reports", - items: [ - { id: "reports", label: "Reports", icon: }, - ], - }, -]; - -// ── Tab titles ──────────────────────────────────────────────────────────────── - -const TAB_TITLES: Record = { - scenes: "Scenes", - characters: "Characters", - locations: "Locations", - reports: "Reports", -}; - -// Flattened tabs for the mobile section dropdown (kept in sync with MENU). -const TAB_OPTIONS: DropdownOption[] = MENU.flatMap((section) => - section.items.map((item) => ({ - value: item.id, - label: ( - - {item.icon} - {item.label} - - ), - })), -); - // ── Reports placeholder ─────────────────────────────────────────────────────── function ReportsPlaceholder() { @@ -80,6 +23,24 @@ function ReportsPlaceholder() { ); } +// ── Sections ────────────────────────────────────────────────────────────────── +// Every section is rendered at once, stacked in the scroll area and separated by +// its title — there is no tab state and no nav sidebar. + +interface Section { + id: string; + label: string; + icon: ReactNode; + Content: ComponentType; +} + +const SECTIONS: Section[] = [ + { id: "scenes", label: "Scenes", icon: , Content: ScenesStats }, + { id: "characters", label: "Characters", icon: , Content: CharactersStats }, + { id: "locations", label: "Locations", icon: , Content: LocationsStats }, + { id: "reports", label: "Reports", icon: , Content: ReportsPlaceholder }, +]; + // ── Props ───────────────────────────────────────────────────────────────────── interface AnalyticsModalProps { @@ -90,7 +51,6 @@ interface AnalyticsModalProps { // ── Component ───────────────────────────────────────────────────────────────── export default function AnalyticsModal({ isOpen, onClose }: AnalyticsModalProps) { - const [activeTab, setActiveTab] = useState("scenes"); const [mounted, setMounted] = useState(false); const isPhone = useIsPhone(); @@ -108,22 +68,21 @@ export default function AnalyticsModal({ isOpen, onClose }: AnalyticsModalProps) if (!isOpen || !mounted) return null; - const activeStats = ( - <> - {activeTab === "scenes" && } - {activeTab === "characters" && } - {activeTab === "locations" && } - {activeTab === "reports" && } - - ); + const sections = SECTIONS.map(({ id, label, icon, Content }) => ( +
+

+ {icon} + {label} +

+ +
+ )); // ── Mobile ────────────────────────────────────────────────────────────── // A distinct layout that matches the other navbar tool sheets (Production, // Read-aloud, Saves): a full-width panel pinned below the navbar with a - // compact header, a section dropdown, and a scrollable body. Kept separate - // from the desktop two-pane modal because the structures don't overlap. - // The overlay carries a low z-index (below the portaled dropdown menu) so - // the section picker's menu renders above the sheet, not behind it. + // compact header and a scrollable body. Kept separate from the desktop modal + // because the structures don't overlap. if (isPhone) { return createPortal(
@@ -138,20 +97,7 @@ export default function AnalyticsModal({ isOpen, onClose }: AnalyticsModalProps)
-
-
- setActiveTab(value as AnalyticsTab)} - options={TAB_OPTIONS} - className={styles.tabSelectTrigger} - fitContent - portal - /> -
-
- -
{activeStats}
+
{sections}
, document.body, @@ -161,46 +107,23 @@ export default function AnalyticsModal({ isOpen, onClose }: AnalyticsModalProps) // ── Desktop ───────────────────────────────────────────────────────────── // Portal to so the overlay's fixed positioning escapes the navbar's // transform/stacking context (which otherwise becomes its containing block). + // The shell is sized exactly like the dashboard modal (see its stylesheet). return createPortal(
e.stopPropagation()}> - - {/* ── Sidebar ──────────────────────────────────────────────── */} - - - {/* ── Content area ─────────────────────────────────────────── */}
-

{TAB_TITLES[activeTab]}

- +

+ + Analytics +

+
-
{activeStats}
+
{sections}
-
, document.body, diff --git a/components/editor/SceneCardsPanel.module.css b/components/editor/SceneCardsPanel.module.css index cc98cedb..12f0e346 100644 --- a/components/editor/SceneCardsPanel.module.css +++ b/components/editor/SceneCardsPanel.module.css @@ -2,8 +2,9 @@ editor stays mounted behind (see PanelRenderer) so switching views never reinitialises it or drops the ProjectContext editor handle. z-index 12 clears the comment gutter, which floats at that level inside the editor beneath; - equal specificity plus later DOM order puts this on top. Still below the - panel switcher (13), which has to stay reachable to switch back. */ + equal specificity plus later DOM order puts this on top. Still below the edge + handles (13) — the panel switcher and the right-sidebar toggle — which have to + stay reachable while the cards are up. */ .container { position: absolute; inset: 0; diff --git a/components/editor/sidebar/EditorSidebarNavigation.module.css b/components/editor/sidebar/EditorSidebarNavigation.module.css index f138d3a5..9923e995 100644 --- a/components/editor/sidebar/EditorSidebarNavigation.module.css +++ b/components/editor/sidebar/EditorSidebarNavigation.module.css @@ -7,6 +7,9 @@ } .sidebar_content { + /* Containing block for the marker gutter, which is positioned into the right + padding below. */ + position: relative; display: flex; flex-direction: column; gap: 20px; @@ -139,6 +142,30 @@ flex: 1; } +/* One tick per scene the filter keeps, placed at its share of the list's height + — a density read-out of where the matches fall in the screenplay. + Sits in the sidebar's own right padding, outside the rounded panel (which + clips its overflow), so the scene titles keep the full panel width. `top` and + `height` are measured onto the scene list in EditorSidebarNavigation, since + nothing here lays the two out together. Mounted only while a filter is on, and + inert so it can't swallow a drag or a scroll aimed at the list. */ +.marker_gutter { + position: absolute; + right: 3px; + width: 6px; + pointer-events: none; +} + +.marker { + position: absolute; + left: 0; + right: 0; + height: 3px; + border-radius: 2px; + transform: translateY(-50%); + opacity: 0.8; +} + /* Document tree list. A hold anywhere on it opens the tree menu on touch (see DocumentTreeSidebarView), so iOS's native selection / callout must not claim the gesture first — it otherwise starts selecting the text behind the drawer @@ -196,6 +223,55 @@ } } +/* Scene filter trigger, pinned to the right end of the header — inset to line up + with the right edge of the scene items below it. Like .header_btn its vertical + padding is pulled back out with negative margins so the tap target stays + comfortable without making this header taller than the others. */ +.filter_btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; + margin: -4px 20px -4px auto; + padding: 4px; + border: none; + border-radius: 6px; + background: none; + color: var(--secondary-text); + cursor: pointer; + transition: background-color 0.15s; +} + +/* The icon keeps its secondary tint throughout; hover and the active filter + state read from the background and the badge instead. Set on the svg itself, + not inherited from the button: globals.css paints every `svg` with + --primary-text, which outranks anything the parent passes down. */ +.filter_btn svg { + color: var(--secondary-text); +} + +.filter_btn:hover, +.filter_btn_active { + background-color: var(--editor-sidebar-hover); +} + +/* Count of active filters, tucked into the button's top-right corner. */ +.filter_badge { + position: absolute; + top: -3px; + right: -3px; + min-width: 13px; + height: 13px; + padding: 0 3px; + border-radius: 7px; + background-color: var(--primary-text); + color: var(--editor-sidebar); + font-size: 9px; + font-weight: 700; + line-height: 13px; + text-align: center; +} + .list_title { font-size: 1rem; } diff --git a/components/editor/sidebar/EditorSidebarNavigation.tsx b/components/editor/sidebar/EditorSidebarNavigation.tsx index 42b44508..df2d9c86 100644 --- a/components/editor/sidebar/EditorSidebarNavigation.tsx +++ b/components/editor/sidebar/EditorSidebarNavigation.tsx @@ -1,7 +1,7 @@ "use client"; import { join } from "@src/lib/utils/misc"; -import { useContext, useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { useContext, useState, useCallback, useRef, useEffect, useLayoutEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; import { ProjectContext } from "@src/context/ProjectContext"; import { useViewContext } from "@src/context/ViewContext"; @@ -9,8 +9,18 @@ import { Scene } from "@src/lib/screenplay/scenes"; import { focusOnPosition } from "@src/lib/screenplay/editor"; import { moveScene } from "@src/lib/screenplay/scene-reorder"; import { computeSceneLabels } from "@src/lib/screenplay/scene-locking"; -import { Archive, Clapperboard, FolderTree, MessageSquare } from "lucide-react"; +import { Archive, Clapperboard, FolderTree, ListFilter, MessageSquare } from "lucide-react"; +import { + EMPTY_SCENE_FILTER, + SceneFilter, + collectFacetOptions, + computeSceneFacets, + countSceneFilters, + isSceneFilterActive, + sceneMatchesFilter, +} from "@src/lib/screenplay/scene-filters"; import SidebarSceneItem from "./SidebarSceneItem"; +import SceneFilterPanel from "./SceneFilterPanel"; import ShelfSidebarView from "./ShelfSidebarView"; import CommentSidebarView from "./CommentSidebarView"; import DocumentTreeSidebarView from "./DocumentTreeSidebarView"; @@ -24,12 +34,18 @@ import sidebar_nav from "./EditorSidebarNavigation.module.css"; const TOUCH_DRAG_HOLD_MS = 300; const TOUCH_DRAG_CANCEL_PX = 10; +// useLayoutEffect warns on the server; fall back to useEffect there. Aligning +// the marker gutter has to happen before paint, or its ticks flash at the top +// of the sidebar before landing on the list. +const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; + const EditorSidebarNavigation = () => { const t = useTranslations("editorSidebar"); const { scenes, updateScenes, editor, + screenplay, sceneLocking, sceneNumberingStyle, skippedSceneLetters, @@ -39,6 +55,39 @@ const EditorSidebarNavigation = () => { const [activeTab, setActiveTab] = useState<"scenes" | "shelf" | "comments" | "documents">("scenes"); + // Scene filter (characters / locations / times of day), cumulative across + // the three dimensions. Kept here so the dimming survives the panel closing. + const [filter, setFilter] = useState(EMPTY_SCENE_FILTER); + const [filterOpen, setFilterOpen] = useState(false); + const filterBtnRef = useRef(null); + const filterActive = isSceneFilterActive(filter); + + // Facets are re-derived on every screenplay change, so only pay for them + // when something actually consumes them — the panel being open, or a filter + // dimming the list. + const facets = useMemo( + () => (filterOpen || filterActive ? computeSceneFacets(screenplay) : []), + [screenplay, filterOpen, filterActive], + ); + + // Keyed by scene heading position rather than by index: an optimistic drag + // reorder moves the scenes before the screenplay is re-parsed, and position + // keeps each scene matched to its own facets in the meantime. + const facetsByPosition = useMemo(() => new Map(facets.map((f) => [f.position, f])), [facets]); + const facetOptions = useMemo(() => collectFacetOptions(facets), [facets]); + + // Which scenes the filter excludes, in list order. Drives both the greyed + // out items and the marker gutter beside the list. + const filteredOut = useMemo( + () => + scenes.map( + (scene) => filterActive && !sceneMatchesFilter(facetsByPosition.get(scene.position), filter), + ), + [scenes, filterActive, facetsByPosition, filter], + ); + + const showMarkerGutter = activeTab === "scenes" && filterActive; + const [dragIndex, setDragIndex] = useState(null); // indicatorIndex represents the gap where the item will be inserted. // Gap i = "before item i". This way "bottom of item N" and "top of item N+1" @@ -69,6 +118,8 @@ const EditorSidebarNavigation = () => { }, [scenes, sceneLocking, sceneNumberingStyle, skippedSceneLetters, persistentScenes]); const listRef = useRef(null); + const sidebarContentRef = useRef(null); + const gutterRef = useRef(null); const currentSceneRef = useRef(null); const scenesRef = useRef(scenes); const suppressSceneScrollRef = useRef(false); @@ -155,6 +206,39 @@ const EditorSidebarNavigation = () => { list.scrollTo({ top: list.scrollTop + delta, behavior: "smooth" }); }, [currentSceneIndex, leftSidebarOpen]); + // The marker gutter is drawn beside the panel, not in it, so nothing lays it + // out against the scene list — it is measured onto it instead. Written + // straight to the node: a state round-trip would re-render the whole list on + // every resize. + useIsoLayoutEffect(() => { + const list = listRef.current; + const gutter = gutterRef.current; + const content = sidebarContentRef.current; + if (!list || !gutter || !content) return; + + const align = () => { + const listRect = list.getBoundingClientRect(); + const contentRect = content.getBoundingClientRect(); + gutter.style.top = `${listRect.top - contentRect.top}px`; + gutter.style.height = `${listRect.height}px`; + }; + + align(); + // Follows the list through sidebar open/close, window resizes and the + // timeline strip opening above the workspace. + const observer = new ResizeObserver(align); + observer.observe(list); + observer.observe(content); + return () => observer.disconnect(); + }, [showMarkerGutter]); + + // The filter popover belongs to the scenes tab: leaving it shuts the panel, + // while the filter itself is kept so coming back restores the same view. + const selectTab = useCallback((tab: "scenes" | "shelf" | "comments" | "documents") => { + setActiveTab(tab); + setFilterOpen(false); + }, []); + // End any in-progress drag and clear its drop indicator. const resetDrag = useCallback(() => { setDragIndex(null); @@ -309,14 +393,45 @@ const EditorSidebarNavigation = () => { return (
-
+
{activeTab === "scenes" ? ( <>

{t("scenes")}

+
+ {/* Portaled to , so it must not stay up over the + editor once the sidebar it hangs off is shut — a + collapsed column on desktop, a slid-out drawer on + phone. Reopening the sidebar brings it back. */} + {filterOpen && leftSidebarOpen && ( + setFilter(EMPTY_SCENE_FILTER)} + onClose={() => setFilterOpen(false)} + options={facetOptions} + /> + )}
{ index={index} label={display?.label ?? `${index + 1}`} isOmitted={display?.isOmitted ?? false} + isFilteredOut={filteredOut[index]} showDropIndicator={showIndicator} isDragging={dragIndex === index} isCurrent={isCurrent} @@ -364,30 +480,51 @@ const EditorSidebarNavigation = () => {
+ {/* Overview strip: one tick per scene the filter keeps, placed at its + share of the list's height, so it is obvious at a glance whether + the matches cluster or run through the whole screenplay. It sits + in the sidebar's own right padding, beside the panel rather than + inside it, so the scene titles keep the full panel width. */} + {showMarkerGutter && ( +
+ {scenes.map((scene: Scene, index: number) => + filteredOut[index] ? null : ( + + ), + )} +
+ )}
); diff --git a/components/editor/sidebar/SceneFilterPanel.module.css b/components/editor/sidebar/SceneFilterPanel.module.css new file mode 100644 index 00000000..a38b00c7 --- /dev/null +++ b/components/editor/sidebar/SceneFilterPanel.module.css @@ -0,0 +1,221 @@ +/* Scene filter popover. Rendered in a body portal (the sidebar panel clips its + overflow) and positioned from the header button, hence `position: fixed` with + the offsets set inline. */ +.container { + composes: panel from "../../navbar/navbar-shared.module.css"; + position: fixed; + z-index: 100; + width: 280px; + max-height: min(460px, calc(100dvh - 120px)); + display: flex; + flex-direction: column; + border-radius: 16px; + overflow: hidden; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--separator); + flex-shrink: 0; +} + +.title { + font-size: 0.85rem; + font-weight: 600; + color: var(--primary-text); +} + +.header_actions { + display: flex; + align-items: center; + gap: 4px; +} + +/* Both header actions are icon-only glyphs, so they share one look. */ +.clear_btn, +.close_btn { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border: none; + background: none; + color: var(--secondary-text); + cursor: pointer; + border-radius: 4px; +} + +.clear_btn:hover:not(:disabled), +.close_btn:hover { + background-color: var(--tertiary); + color: var(--primary-text); +} + +/* No filter set: nothing to clear. Dimming the whole button carries the icon + with it, whatever colour globals.css paints the glyph. */ +.clear_btn:disabled { + opacity: 0.4; + cursor: default; +} + +.body { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + overflow-y: auto; + min-height: 0; +} + +.section { + display: flex; + flex-direction: column; +} + +/* Closed dropdown: dimension name on the left, current selection on the right. + The menu below is laid out in flow rather than floating, so a long list can + scroll inside the panel instead of being clipped by it. */ +.trigger { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 10px; + border: 1px solid var(--separator); + border-radius: 8px; + background-color: var(--secondary); + font-size: 0.8rem; + text-align: left; + color: var(--primary-text); + cursor: pointer; + transition: + background-color 0.15s ease, + border-color 0.15s ease; +} + +.trigger:hover:not(:disabled) { + background-color: var(--secondary-hover); +} + +.trigger:disabled { + opacity: 0.5; + cursor: default; +} + +.trigger_active { + border-color: var(--primary-text); +} + +/* Set on the glyph, not the wrapper: globals.css paints every `svg` with + --primary-text, which outranks an inherited colour. */ +.trigger_icon svg { + display: block; + color: var(--secondary-text); +} + +.trigger_icon { + display: flex; + flex-shrink: 0; +} + +.trigger_label { + flex-shrink: 0; + white-space: nowrap; +} + +/* Fills the gap between the label and the chevron, so the selection sits hard + against the chevron however long the dimension name is. */ +.trigger_value { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + text-align: right; + font-size: 0.75rem; + color: var(--secondary-text); +} + +.chevron { + flex-shrink: 0; + color: var(--secondary-text); + transition: transform 0.2s ease; +} + +.chevron_open { + transform: rotate(180deg); +} + +.menu { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 4px; + padding: 4px; + border: 1px solid var(--separator); + border-radius: 8px; + background-color: var(--primary); + max-height: 180px; + overflow-y: auto; +} + +.option { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + width: 100%; + padding: 5px 8px; + border: none; + border-radius: 6px; + background: none; + font-size: 0.8rem; + text-align: left; + color: var(--secondary-text); + cursor: pointer; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +/* Typing inside an open menu walks to the matching row, which is shown as + though the pointer were on it. */ +.option:hover, +.option_highlighted { + background-color: var(--secondary-hover); + color: var(--primary-text); +} + +.option_selected { + color: var(--primary-text); +} + +.option_label { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.option_count { + flex-shrink: 0; + font-size: 0.7rem; + font-variant-numeric: tabular-nums; + color: var(--secondary-text); + opacity: 0.7; +} + +/* Always laid out, checked or not, so rows don't shift as they are picked. */ +.option_check { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 12px; +} diff --git a/components/editor/sidebar/SceneFilterPanel.tsx b/components/editor/sidebar/SceneFilterPanel.tsx new file mode 100644 index 00000000..04845d52 --- /dev/null +++ b/components/editor/sidebar/SceneFilterPanel.tsx @@ -0,0 +1,291 @@ +"use client"; + +import { RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslations } from "next-intl"; +import { Check, ChevronDown, Clock, Compass, FilterX, MapPin, Users, X } from "lucide-react"; +import { join } from "@src/lib/utils/misc"; +import { + FacetOption, + SceneFilter, + isSceneFilterActive, + toggleFilterValue, +} from "@src/lib/screenplay/scene-filters"; + +import styles from "./SceneFilterPanel.module.css"; + +// Layout effect on the client (flash-free positioning), plain effect on the +// server to avoid React's "useLayoutEffect does nothing on the server" warning. +const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; + +const PANEL_WIDTH = 280; +const VIEWPORT_MARGIN = 8; +/** How long typed letters keep accumulating before the next one starts a fresh + * search — the same idea as a native