diff --git a/apps/web/src/components/app-page-shell.tsx b/apps/web/src/components/app-page-shell.tsx new file mode 100644 index 0000000..d963e76 --- /dev/null +++ b/apps/web/src/components/app-page-shell.tsx @@ -0,0 +1,123 @@ +import type { ReactNode } from "react"; + +import { Badge } from "@open-learn/ui/components/badge"; +import { cn } from "@open-learn/ui/lib/utils"; + +export function AppPage({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
; +} + +export function AppPageHeader({ + eyebrow, + title, + description, + actions, + children, + className, +}: { + eyebrow?: string; + title: string; + description?: string; + actions?: ReactNode; + children?: ReactNode; + className?: string; +}) { + return ( +
+
+ {eyebrow ? ( +

+ {eyebrow} +

+ ) : null} +

{title}

+ {description ? ( +

{description}

+ ) : null} + {children ?
{children}
: null} +
+ + {actions ? ( +
{actions}
+ ) : null} +
+ ); +} + +export function AppPageHeaderMeta({ + items, + className, +}: { + items: Array<{ label: string; value: string }>; + className?: string; +}) { + return ( +
+ {items.map((item) => ( + + {item.label} + {item.value} + + ))} +
+ ); +} + +export function AppSurface({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +export function AppSurfaceHeader({ + label, + title, + description, + actions, + className, +}: { + label?: string; + title?: string; + description?: string; + actions?: ReactNode; + className?: string; +}) { + return ( +
+
+ {label ? ( +

+ {label} +

+ ) : null} + {title ?

{title}

: null} + {description ?

{description}

: null} +
+ + {actions ? ( +
{actions}
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/archived-badge.tsx b/apps/web/src/components/archived-badge.tsx new file mode 100644 index 0000000..b856de2 --- /dev/null +++ b/apps/web/src/components/archived-badge.tsx @@ -0,0 +1,18 @@ +import { cn } from "@open-learn/ui/lib/utils"; + +interface ArchivedBadgeProps { + className?: string; +} + +export function ArchivedBadge({ className }: ArchivedBadgeProps) { + return ( + + archived + + ); +} diff --git a/apps/web/src/components/row-actions.tsx b/apps/web/src/components/row-actions.tsx new file mode 100644 index 0000000..a002d9b --- /dev/null +++ b/apps/web/src/components/row-actions.tsx @@ -0,0 +1,94 @@ +import type { ReactNode } from "react"; + +import { ArchiveIcon, ArchiveRestoreIcon, MoreVerticalIcon, Trash2Icon } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@open-learn/ui/components/alert-dialog"; +import { Button } from "@open-learn/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@open-learn/ui/components/dropdown-menu"; + +interface RowActionsProps { + isArchived: boolean; + onToggleArchive: () => void; + isArchivePending: boolean; + onDelete: () => void; + deleteTitle: string; + deleteDescription: ReactNode; +} + +export function RowActions({ + isArchived, + onToggleArchive, + isArchivePending, + onDelete, + deleteTitle, + deleteDescription, +}: RowActionsProps) { + return ( + + + + + + + + {isArchived ? ( + <> + + Unarchive + + ) : ( + <> + + Archive + + )} + + + + e.preventDefault()} + > + + Delete + + + + + + + + {deleteTitle} + {deleteDescription} + + + Cancel + + Delete + + + + + ); +} diff --git a/apps/web/src/features/auth/components/oauth-buttons.tsx b/apps/web/src/features/auth/components/oauth-buttons.tsx index 61af5cb..3c942e4 100644 --- a/apps/web/src/features/auth/components/oauth-buttons.tsx +++ b/apps/web/src/features/auth/components/oauth-buttons.tsx @@ -33,9 +33,10 @@ export default function OAuthButtons({ invitationId }: { invitationId?: string } } return ( -
+
-
- )} - - + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + })} + > + {({ canSubmit, isSubmitting }) => ( +
+ +
+ )} +
+ + ) : null} diff --git a/apps/web/src/features/auth/components/sign-in-form.tsx b/apps/web/src/features/auth/components/sign-in-form.tsx index d56e810..96ae71f 100644 --- a/apps/web/src/features/auth/components/sign-in-form.tsx +++ b/apps/web/src/features/auth/components/sign-in-form.tsx @@ -86,9 +86,7 @@ export default function SignInForm({
- - or sign in with email - + or sign in with email
diff --git a/apps/web/src/features/auth/components/sign-up-form.tsx b/apps/web/src/features/auth/components/sign-up-form.tsx index 6d28c53..334d2aa 100644 --- a/apps/web/src/features/auth/components/sign-up-form.tsx +++ b/apps/web/src/features/auth/components/sign-up-form.tsx @@ -88,9 +88,7 @@ export default function SignUpForm({
- - or sign up with email - + or sign up with email
diff --git a/apps/web/src/features/clients/components/client-row-actions.tsx b/apps/web/src/features/clients/components/client-row-actions.tsx index b8b63ef..0504f9a 100644 --- a/apps/web/src/features/clients/components/client-row-actions.tsx +++ b/apps/web/src/features/clients/components/client-row-actions.tsx @@ -1,25 +1,6 @@ import type { Client } from "@open-learn/api/modules/client/client.schema"; -import { MoreVerticalIcon, ArchiveIcon, ArchiveRestoreIcon, Trash2Icon } from "lucide-react"; -import { Button } from "@open-learn/ui/components/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@open-learn/ui/components/dropdown-menu"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@open-learn/ui/components/alert-dialog"; +import { RowActions } from "@/components/row-actions"; import { useDeleteClient, useUpdateClient } from "../services/mutations"; @@ -31,68 +12,19 @@ export function ClientRowActions({ client }: ClientRowActionsProps) { const updateClient = useUpdateClient(); const deleteClient = useDeleteClient(); - function handleToggleArchive() { - updateClient.mutate({ id: client.id, isArchived: !client.isArchived }); - } - - function handleDelete() { - deleteClient.mutate({ id: client.id }); - } - return ( - - - - - - - - {client.isArchived ? ( - <> - - Unarchive - - ) : ( - <> - - Archive - - )} - - - - e.preventDefault()} - > - - Delete - - - - - - - - Delete client - - Are you sure you want to permanently delete{" "} - {client.name}? This action cannot be undone. - - - - Cancel - - Delete - - - - + updateClient.mutate({ id: client.id, isArchived: !client.isArchived })} + isArchivePending={updateClient.isPending} + onDelete={() => deleteClient.mutate({ id: client.id })} + deleteTitle="Delete client" + deleteDescription={ + <> + Are you sure you want to permanently delete{" "} + {client.name}? This action cannot be undone. + + } + /> ); } diff --git a/apps/web/src/features/clients/pages/clients-page.tsx b/apps/web/src/features/clients/pages/clients-page.tsx index 1b98048..ad7a285 100644 --- a/apps/web/src/features/clients/pages/clients-page.tsx +++ b/apps/web/src/features/clients/pages/clients-page.tsx @@ -1,17 +1,25 @@ import type { Client } from "@open-learn/api/modules/client/client.schema"; import { useState } from "react"; -import { PencilIcon, SearchIcon, ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon, PencilIcon, SearchIcon } from "lucide-react"; + +import { + AppPage, + AppPageHeader, + AppPageHeaderMeta, + AppSurface, + AppSurfaceHeader, +} from "@/components/app-page-shell"; import { Button } from "@open-learn/ui/components/button"; import { Checkbox } from "@open-learn/ui/components/checkbox"; -import { Input } from "@open-learn/ui/components/input"; -import { Skeleton } from "@open-learn/ui/components/skeleton"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@open-learn/ui/components/dropdown-menu"; +import { Input } from "@open-learn/ui/components/input"; +import { Skeleton } from "@open-learn/ui/components/skeleton"; import { Table, TableBody, @@ -21,10 +29,13 @@ import { TableRow, } from "@open-learn/ui/components/table"; -import { useClientsQuery } from "../services/queries"; -import { useCreateClient } from "../services/mutations"; +import { ArchivedBadge } from "@/components/archived-badge"; +import { useTableSelection } from "@/hooks/use-table-selection"; + import { ClientInlineEditor } from "../components/client-inline-editor"; import { ClientRowActions } from "../components/client-row-actions"; +import { useCreateClient } from "../services/mutations"; +import { useClientsQuery } from "../services/queries"; type FilterMode = "active" | "archived" | "all"; @@ -39,99 +50,111 @@ export default function ClientsPage() { const [search, setSearch] = useState(""); const [editingId, setEditingId] = useState(null); const [newName, setNewName] = useState(""); - const [selectedIds, setSelectedIds] = useState>(new Set()); const showArchived = filter === "archived" || filter === "all"; const { data: clients, isLoading } = useClientsQuery(showArchived); const createClient = useCreateClient(); - const filtered = (clients ?? []).filter((c) => { - const matchesSearch = c.name.toLowerCase().includes(search.toLowerCase()); - if (filter === "active") return matchesSearch && !c.isArchived; - if (filter === "archived") return matchesSearch && c.isArchived; + const filtered = (clients ?? []).filter((client) => { + const matchesSearch = client.name.toLowerCase().includes(search.toLowerCase()); + + if (filter === "active") { + return matchesSearch && !client.isArchived; + } + + if (filter === "archived") { + return matchesSearch && client.isArchived; + } + return matchesSearch; }); + const { selectedIds, toggleSelect, toggleSelectAll, allSelected, someSelected } = + useTableSelection({ items: filtered }); + async function handleAdd() { - if (!newName.trim()) return; + if (!newName.trim()) { + return; + } + await createClient.mutateAsync({ name: newName.trim(), currency: "USD" }); setNewName(""); } - function toggleSelect(id: number) { - setSelectedIds((prev) => { - const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); - return next; - }); - } + const activeCount = (clients ?? []).filter((client) => !client.isArchived).length; - function toggleSelectAll() { - if (selectedIds.size === filtered.length) { - setSelectedIds(new Set()); - } else { - setSelectedIds(new Set(filtered.map((c) => c.id))); - } - } + return ( + + + + - const allSelected = filtered.length > 0 && selectedIds.size === filtered.length; - const someSelected = selectedIds.size > 0 && selectedIds.size < filtered.length; + + - return ( -
-

Clients

- - {/* Toolbar */} -
-
- {/* Filter dropdown */} - - - - - - setFilter("active")}>Show active - setFilter("archived")}> - Show archived - - setFilter("all")}>Show all - - - - {/* Search */} -
- +
+
+ + + + + + setFilter("active")}>Show active + setFilter("archived")}> + Show archived + + setFilter("all")}>Show all + + + +
+ + setSearch(event.target.value)} + placeholder="Search by name" + className="w-56 pl-8" + /> +
+
+ +
setSearch(e.target.value)} - placeholder="Search by name" - className="pl-8 w-56" + value={newName} + onChange={(event) => setNewName(event.target.value)} + placeholder="Add new client" + className="w-52" + onKeyDown={(event) => { + if (event.key === "Enter") { + void handleAdd(); + } + }} /> +
- {/* Add new client */} -
- setNewName(e.target.value)} - placeholder="Add new client" - className="w-52" - onKeyDown={(e) => { - if (e.key === "Enter") handleAdd(); - }} - /> - -
-
- - {/* Table */} -
@@ -178,8 +201,8 @@ export default function ClientsPage() { )}
-
-
+ + ); } @@ -202,11 +225,7 @@ function ClientTableRow({ client, selected, onToggleSelect, onEdit }: ClientTabl {client.name} - {client.isArchived && ( - - archived - - )} + {client.isArchived ? : null} {client.address ?? ""} {client.currency} @@ -231,8 +250,8 @@ function ClientTableRow({ client, selected, onToggleSelect, onEdit }: ClientTabl function ClientTableSkeleton() { return ( <> - {[1, 2, 3].map((i) => ( - + {[1, 2, 3].map((item) => ( + diff --git a/apps/web/src/features/home/pages/home-page.tsx b/apps/web/src/features/home/pages/home-page.tsx index 3ab58ae..461ab1d 100644 --- a/apps/web/src/features/home/pages/home-page.tsx +++ b/apps/web/src/features/home/pages/home-page.tsx @@ -1,15 +1,15 @@ import { useState } from "react"; -import { ArrowUpRightIcon, ChevronRightIcon, Clock3Icon, ImageOffIcon } from "lucide-react"; +import { ArrowUpRightIcon, ChevronRightIcon, ImageOffIcon } from "lucide-react"; import { LazyMotion, domAnimation, m } from "motion/react"; +import { useTheme } from "next-themes"; +import { Badge } from "@open-learn/ui/components/badge"; import { Button } from "@open-learn/ui/components/button"; import { Link } from "@tanstack/react-router"; -import heroAvif from "@/public/example-hero.avif"; -import heroAvif832 from "@/public/example-hero-832.avif"; -import heroWebp from "@/public/example-hero.webp"; -import heroWebp832 from "@/public/example-hero-832.webp"; -import heroPng from "@/public/example-hero.png"; +import heroDark from "@/public/hero-image-dark.png"; +import heroLight from "@/public/hero-image-light.png"; +import logoSvg from "@/public/logo.svg"; const GITHUB_URL = "https://github.com/Berget1411/open-clock"; @@ -28,10 +28,12 @@ function HeroImageFallback() { export default function HomePage() { const [heroError, setHeroError] = useState(false); + const { resolvedTheme } = useTheme(); + const heroSrc = resolvedTheme === "light" ? heroLight : heroDark; return ( -
+
- Open Clock + better clock + + Beta +