Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 1 addition & 11 deletions apps/web/app/components/ActionDetailView.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,7 @@
// FIXME: Refactor !
import { useEffect, useState } from "react";
import { Button, cn, toast } from "@kreozalabs/kei-ui";
import {
Calendar,
Clock,
CheckCircle2,
Trash2,
XCircle,
RotateCcw,
Pencil,
Loader2,
} from "lucide-react";
import { Calendar, Clock, CheckCircle2, Trash2, XCircle, RotateCcw, Loader2 } from "lucide-react";
import type { Action } from "@kreozalabs/kei-core";
import type { Event } from "@kreozalabs/kei-core";
import {
Expand All @@ -24,7 +15,6 @@ import { getEventsForEntity, updateAction } from "@/db/actions";
import { EditableTitle } from "./action-input/EditableTitle";
import { EditableNote } from "./action-input/EditableNote";
import { useQueryClient } from "@tanstack/react-query";
import { useSettings } from "@/providers/SettingsContext";

const STATUS_BADGE_STYLES: Record<string, string> = {
[ACTION_STATUS.COMPLETED]: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20",
Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/components/action-input/PropertyButton.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Button, cn } from "@kreozalabs/kei-ui";
import { type ReactNode, forwardRef } from "react";
import { type ButtonHTMLAttributes, type ReactNode, forwardRef } from "react";

interface PropertyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
interface PropertyButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "value"> {
icon?: ReactNode;
label?: string;
isActive?: boolean;
Expand Down
41 changes: 9 additions & 32 deletions apps/web/app/components/action-input/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Action } from "@kreozalabs/kei-core";
import { forwardRef, useEffect, useRef, useState } from "react";
import { forwardRef, useEffect, useRef, useState, useImperativeHandle } from "react";
import { Button } from "@kreozalabs/kei-ui";
import { $getRoot, type EditorState } from "lexical";
import { EditableIntention, type IntentionType } from "./EditableIntention";
import { EditableImportant } from "./EditableImportant";
import { PropertyButton } from "./PropertyButton";
Expand Down Expand Up @@ -29,9 +28,15 @@ interface ActionInputHandle {
}

export const ActionInput = forwardRef<ActionInputHandle, ActionInputProps>(
({ onSuccess, onCancel, initialDate, className, variant = "inline", actionToEdit }, ref) => {
({ onSuccess, onCancel }, ref) => {
const [isLoading, setIsLoading] = useState(false);

useImperativeHandle(ref, () => ({
handleCancelAttempt: () => {
onCancel?.();
},
}));

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
Expand All @@ -57,29 +62,6 @@ export const ActionInput = forwardRef<ActionInputHandle, ActionInputProps>(
}, 500);
};

// State for all editable fields
const [title, setTitle] = useState(actionToEdit?.title || "");
const [note, setNote] = useState(actionToEdit?.note || "");

const handleEditorChange = (editorState: EditorState) => {
editorState.read(() => {
const root = $getRoot();
const children = root.getChildren();
if (children.length > 0) {
setTitle(children[0].getTextContent());
}
if (children.length > 1) {
const noteText = children
.slice(1)
.map((c) => c.getTextContent())
.join("\n");
setNote(noteText);
} else {
setNote("");
}
});
};

const [intention, setIntention] = useState<IntentionType>("want");
const [important, setImportant] = useState(false);
// TODO: Allow user to configure what is shown by default. For that, we should be able to loop over some settings instead of hard-coding what is shown.
Expand Down Expand Up @@ -143,12 +125,7 @@ export const ActionInput = forwardRef<ActionInputHandle, ActionInputProps>(
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button
type="submit"
size="sm"
disabled={isLoading || !title.trim()}
className="gap-1.5 px-4"
>
<Button type="submit" size="sm" disabled={isLoading} className="gap-1.5 px-4">
<span>Save</span>
<SendHorizontal className="size-3.5" />
</Button>
Expand Down
47 changes: 47 additions & 0 deletions apps/web/app/components/dashboard/DashboardHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// NOTE: On mobile order is different: {calendar that opens down moving page} {space} {search} {today} {view switcher}
// NOTE: Otherwise: {title} {space} {today} {arrows} {calendar that opens as popover}{space}{search}{view switcher}{new action}

import {
AppHeader,
HeaderTitleArea,
HeaderSearch,
HeaderNewAction,
} from "@/components/layout/AppHeader";
import { ViewSwitcher } from "@/routes/app/dashboard/components/ViewSwitcher";

export const DashboardHeader = () => {
return (
<AppHeader>
<div className="flex w-full flex-col">
{/* Main Header Row */}
<div className="flex w-full items-center justify-between">
{/* Mobile Left: Calendar Trigger portal target */}
<div id="calendar-mobile-trigger-target" className="mr-2 flex min-w-0 flex-1 md:hidden" />

{/* Desktop Left: Title Area & Today, Arrows, Calendar Popover portal target */}
<div className="hidden items-center gap-2 md:flex">
<HeaderTitleArea title="Timeline" />
<div
id="calendar-desktop-controls-target"
className="hidden items-center gap-4 md:ml-5 md:flex lg:ml-18"
/>
</div>

{/* Right Section: Actions */}
<div className="flex shrink-0 items-center gap-1 md:gap-4">
<HeaderSearch />

{/* Mobile Today portal target */}
<div id="calendar-mobile-today-target" className="flex md:hidden" />

<ViewSwitcher />
<HeaderNewAction />
</div>
</div>

{/* Mobile Dropdown Calendar Panel target */}
<div id="calendar-mobile-dropdown-target" className="w-full md:hidden" />
</div>
</AppHeader>
);
};
185 changes: 100 additions & 85 deletions apps/web/app/components/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { forwardRef } from "react";
import { PlusIcon, SearchIcon, MoreVerticalIcon, Loader2Icon } from "lucide-react";
import { Button, cn } from "@kreozalabs/kei-ui";
import { forwardRef, useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { PlusIcon, SearchIcon, Loader2Icon } from "lucide-react";
import { Button, cn, useMediaQuery } from "@kreozalabs/kei-ui";
import { useSettings } from "@/providers/SettingsContext";
import { useSubtleOnIdle } from "@/hooks/useSubtleOnIdle";
import { useDb } from "@/providers/DbContext";
import { useOutletContext } from "react-router";
import type { AppLayoutContext } from "./AppLayout";

interface AppHeaderProps {
title: string;
title?: string;
subtitle?: string;
left?: React.ReactNode;
center?: React.ReactNode;
right?: React.ReactNode;
icon?: React.ReactNode;
className?: string;
children?: React.ReactNode;
}

export function AppHeader({ title, subtitle, left, center, right }: AppHeaderProps) {
export function AppHeader({ title, subtitle, icon, className, children }: AppHeaderProps) {
const { settings } = useSettings();
const { isSubtle, show, hide } = useSubtleOnIdle({
initialDelay: 3000,
Expand All @@ -22,111 +25,123 @@ export function AppHeader({ title, subtitle, left, center, right }: AppHeaderPro
disabled: !settings.subtle_on_idle,
});

const { isDbReady, isWriting } = useDb();
const isLoading = !isDbReady || isWriting;
const isMobile = useMediaQuery("(max-width: 768px)");
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);

return (
<header
className="bg-background/95 border-border/40 sticky top-0 z-40 w-full shrink-0 border-b px-6 pt-2 pb-1 backdrop-blur-xl md:border-none md:px-8 md:pt-4 md:pb-6"
useEffect(() => {
if (typeof window !== "undefined") {
const frameId = requestAnimationFrame(() => {
if (!isMobile) {
setPortalTarget(document.getElementById("global-header-content"));
} else {
setPortalTarget(null);
}
});
return () => cancelAnimationFrame(frameId);
}
}, [isMobile]);

const headerContent = (
<div
className={cn(
"flex w-full cursor-default items-center gap-4 transition-[opacity,transform] duration-1000 ease-in-out",
isSubtle ? "translate-y-0.5 opacity-20" : "opacity-100"
)}
onMouseEnter={show}
onMouseMove={show}
onMouseLeave={hide}
>
<div
{children ? (
children
) : (
<HeaderTitleArea title={title || ""} subtitle={subtitle} icon={icon} />
)}
</div>
);

if (isMobile) {
return (
<header
className={cn(
"flex w-full cursor-default gap-4 transition-[opacity,transform] duration-1000 ease-in-out",
isSubtle ? "translate-y-0.5 opacity-20" : "opacity-100"
"bg-muted/95 border-border/40 sticky top-0 z-40 w-full shrink-0 border-b px-6 pt-2 pb-1 backdrop-blur-xl md:h-20",
className
)}
>
{/* 1. Left Area (e.g. Sidebar toggle) */}
{left && <div className="flex shrink-0 items-center">{left}</div>}

{/* 2. Title Area (Reserved space for stability) */}
<div className="flex h-10 min-w-0 flex-1 flex-col justify-end gap-1 md:ml-12 md:h-16">
<h1 className="mb-2 flex items-center gap-2 text-lg leading-none font-bold tracking-tight md:text-xl">
<span>{title}</span>
{isLoading && (
<Loader2Icon
className="text-muted-foreground/60 size-4 shrink-0 animate-spin"
aria-hidden="true"
/>
)}
</h1>
<div className="flex h-4 items-center overflow-hidden">
{subtitle ? (
<span className="text-muted-foreground truncate text-[11px] font-semibold tracking-wider opacity-70 md:text-xs">
{subtitle}
</span>
) : (
<span className="invisible text-[11px] uppercase">placeholder</span>
)}
</div>
</div>

{/* 3. Actions Area (Right-aligned grouping) */}
<div className="flex h-12 min-w-0 flex-1 items-center justify-end gap-6 md:gap-10">
{/* Search stays grouped with the other actions */}
<div className="hidden items-center lg:flex">{center}</div>

{/* Secondary Actions */}
<div className="flex items-center gap-2 font-medium md:gap-6">
<div className="text-muted-foreground/40 lg:hidden">{center}</div>
{right}
</div>
</div>
{headerContent}
</header>
);
}

if (!portalTarget) return null;
return createPortal(headerContent, portalTarget);
}

interface HeaderTitleAreaProps {
title: string;
subtitle?: string;
icon?: React.ReactNode;
className?: string;
}

export function HeaderTitleArea({ title, subtitle, icon, className }: HeaderTitleAreaProps) {
const { isDbReady, isWriting } = useDb();
const isLoading = !isDbReady || isWriting;

return (
<div className={cn("flex min-w-0 items-center gap-2.5", className)}>
{icon && (
<div className="text-primary/80 flex shrink-0 items-center justify-center">{icon}</div>
)}
<div className="flex min-w-0 flex-col justify-center">
<h1 className="flex items-center gap-2 text-base font-bold tracking-tight md:text-lg">
<span>{title}</span>
{isLoading && (
<Loader2Icon
className="text-muted-foreground/60 size-3.5 shrink-0 animate-spin"
aria-hidden="true"
/>
)}
</h1>
{subtitle && (
<p className="text-muted-foreground/60 mt-0.5 truncate text-xs font-normal">{subtitle}</p>
)}
</div>
</header>
</div>
);
}

/**
* Reusable Header Action Components
* Routes can import these to maintain consistency while staying explicit.
*/

export function HeaderSearch() {
return (
<Button
variant="ghost"
size="sm"
className="hover:bg-muted/50 text-muted-foreground flex h-8 items-center gap-2 rounded-md border-none px-2 font-medium md:px-3"
>
<SearchIcon className="size-4 md:size-4" />
<span className="hidden text-xs md:inline">Search</span>
<span className="hidden text-[10px] opacity-40 md:inline">Ctrl+K</span>
<Button variant="ghost" size="icon" className="size-10">
<SearchIcon className="size-5" />
</Button>
);
}

export const HeaderNewAction = forwardRef<HTMLButtonElement, { onClick?: () => void }>(
({ onClick, ...props }, ref) => {
const context = useOutletContext<AppLayoutContext | null>();

// If the FAB is explicitly disabled (undefined), hide the header button too
if (context && context.onFabClick === undefined && !onClick) {
return null;
}

const handleClick = onClick || context?.onFabClick || context?.openActionInput;

return (
<Button
ref={ref}
variant="default"
size="sm"
onClick={onClick}
className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-primary/20 hover:shadow-primary/30 hidden h-9 items-center gap-1.5 rounded-full border-none px-4 font-bold shadow-lg transition-all active:scale-95 md:flex"
size="icon"
onClick={handleClick}
className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-primary/30 hidden h-10 w-20 items-center justify-center rounded-xl border-none shadow-lg transition-all active:scale-95 md:flex"
{...props}
>
<PlusIcon className="size-4" />
<span className="text-sm">New Action</span>
<span className="ml-1 hidden text-[10px] font-medium opacity-40 lg:inline">N</span>
<PlusIcon className="size-5" />
</Button>
);
}
);
HeaderNewAction.displayName = "HeaderNewAction";

export function HeaderMore({ onClick }: { onClick?: () => void }) {
return (
<Button
variant="ghost"
size="icon"
onClick={onClick}
className="hover:bg-muted/50 text-muted-foreground size-8 rounded-md border-none"
>
<MoreVerticalIcon className="size-4" />
</Button>
);
}
HeaderNewAction.displayName = "HeaderNewAction";
Loading