Updated sidebar layout (#1661)

This commit is contained in:
Luke Vella 2025-04-14 15:11:59 +01:00 committed by GitHub
parent 8c0814b92b
commit 72ca1d4c38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
104 changed files with 3268 additions and 1331 deletions

View file

@ -0,0 +1,35 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
function cmdKey(e: KeyboardEvent) {
if (e.metaKey || e.ctrlKey) {
return e.key;
}
return false;
}
export function CommandGlobalShortcut({ trigger }: { trigger: () => void }) {
const router = useRouter();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (cmdKey(e)) {
case "k":
e.preventDefault();
trigger();
break;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [router, trigger]);
// This component doesn't render anything
return null;
}

View file

@ -0,0 +1,115 @@
"use client";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@rallly/ui/command";
import { DialogDescription, DialogTitle, useDialog } from "@rallly/ui/dialog";
import { PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import {
BillingPageIcon,
EventPageIcon,
HomePageIcon,
PageIcon,
PollPageIcon,
PreferencesPageIcon,
ProfilePageIcon,
} from "@/app/components/page-icons";
import { Trans } from "@/components/trans";
import { CommandGlobalShortcut } from "./command-global-shortcut";
export function CommandMenu() {
const router = useRouter();
const { trigger, dialogProps, dismiss } = useDialog();
const handleSelect = (route: string) => {
router.push(route);
dismiss();
};
return (
<>
<CommandGlobalShortcut trigger={trigger} />
{/* <Button variant="ghost" onClick={trigger}>
<Icon>
<SearchIcon />
</Icon>
<Trans i18nKey="search" defaults="Search" />
<CommandShortcutSymbol symbol="K" />
</Button> */}
<CommandDialog {...dialogProps}>
<DialogTitle className="sr-only">
<Trans i18nKey="commandMenu" defaults="Command Menu" />
</DialogTitle>
<DialogDescription className="sr-only">
<Trans i18nKey="commandMenuDescription" defaults="Select a command" />
</DialogDescription>
<CommandInput
autoFocus={true}
placeholder="Type a command or search..."
/>
<CommandList className="max-h-max">
<CommandEmpty>
<span>
<Trans i18nKey="commandMenuNoResults" defaults="No results" />
</span>
</CommandEmpty>
<CommandGroup heading={<Trans i18nKey="polls" defaults="Actions" />}>
<CommandItem onSelect={() => handleSelect("/new")}>
<PageIcon>
<PlusIcon />
</PageIcon>
<Trans i18nKey="create" defaults="Create" />
</CommandItem>
</CommandGroup>
<CommandGroup heading="Navigation">
<CommandItem onSelect={() => handleSelect("/")}>
<HomePageIcon />
<Trans i18nKey="home" defaults="Home" />
</CommandItem>
<CommandItem onSelect={() => handleSelect("/polls")}>
<PollPageIcon />
<Trans i18nKey="polls" defaults="Polls" />
</CommandItem>
<CommandItem onSelect={() => handleSelect("/events")}>
<EventPageIcon />
<Trans i18nKey="events" defaults="Events" />
</CommandItem>
{/* <CommandItem onSelect={() => handleSelect("/teams")}>
<TeamsPageIcon />
<Trans i18nKey="teams" defaults="Teams" />
</CommandItem>
<CommandItem onSelect={() => handleSelect("/spaces")}>
<SpacesPageIcon />
<Trans i18nKey="spaces" defaults="Spaces" />
</CommandItem> */}
</CommandGroup>
<CommandGroup
heading={<Trans i18nKey="account" defaults="Account" />}
>
<CommandItem onSelect={() => handleSelect("/settings/profile")}>
<ProfilePageIcon />
<Trans i18nKey="profile" defaults="Profile" />
</CommandItem>
<CommandItem onSelect={() => handleSelect("/settings/preferences")}>
<PreferencesPageIcon />
<Trans i18nKey="preferences" defaults="Preferences" />
</CommandItem>
<CommandItem onSelect={() => handleSelect("/settings/billing")}>
<BillingPageIcon />
<Trans i18nKey="billing" defaults="Billing" />
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
</>
);
}

View file

@ -0,0 +1 @@
export * from "./command-menu";

View file

@ -0,0 +1,130 @@
"use client";
import type { ReactNode } from "react";
import { createContext, useCallback, useMemo, useState } from "react";
import { useRequiredContext } from "@/components/use-required-context";
import { PollSelectionActionBar } from "./poll-selection-action-bar";
type RowSelectionState = Record<string, boolean>;
type PollSelectionContextType = {
selectedPolls: RowSelectionState;
setSelectedPolls: (selection: RowSelectionState) => void;
selectPolls: (pollIds: string[]) => void;
unselectPolls: (pollIds: string[]) => void;
togglePollSelection: (pollId: string) => void;
clearSelection: () => void;
isSelected: (pollId: string) => boolean;
getSelectedPollIds: () => string[];
selectedCount: number;
};
const PollSelectionContext = createContext<PollSelectionContextType | null>(
null,
);
type PollSelectionProviderProps = {
children: ReactNode;
};
export const PollSelectionProvider = ({
children,
}: PollSelectionProviderProps) => {
const [selectedPolls, setSelectedPolls] = useState<RowSelectionState>({});
const selectPolls = useCallback((pollIds: string[]) => {
setSelectedPolls((prev) => {
const newSelection = { ...prev };
pollIds.forEach((id) => {
newSelection[id] = true;
});
return newSelection;
});
}, []);
const unselectPolls = useCallback(
(pollIds: string[]) =>
setSelectedPolls((prev) => {
const newSelection = { ...prev };
pollIds.forEach((id) => {
delete newSelection[id];
});
return newSelection;
}),
[],
);
const togglePollSelection = useCallback(
(pollId: string) =>
setSelectedPolls((prev) => {
const newSelection = { ...prev };
if (newSelection[pollId]) {
delete newSelection[pollId];
} else {
newSelection[pollId] = true;
}
return newSelection;
}),
[],
);
const clearSelection = useCallback(() => setSelectedPolls({}), []);
const isSelected = useCallback(
(pollId: string) => Boolean(selectedPolls[pollId]),
[selectedPolls],
);
const getSelectedPollIds = useCallback(
() => Object.keys(selectedPolls),
[selectedPolls],
);
const selectedCount = useMemo(
() => Object.keys(selectedPolls).length,
[selectedPolls],
);
const value = useMemo(
() => ({
selectedPolls,
setSelectedPolls,
selectPolls,
unselectPolls,
togglePollSelection,
clearSelection,
isSelected,
getSelectedPollIds,
selectedCount,
}),
[
selectedPolls,
setSelectedPolls,
selectPolls,
unselectPolls,
togglePollSelection,
clearSelection,
isSelected,
getSelectedPollIds,
selectedCount,
],
);
return (
<PollSelectionContext.Provider value={value}>
{children}
<PollSelectionActionBar />
</PollSelectionContext.Provider>
);
};
export const usePollSelection = () => {
const context = useRequiredContext(
PollSelectionContext,
"usePollSelection must be used within a PollSelectionProvider",
);
return context;
};

View file

@ -0,0 +1,142 @@
"use client";
import {
ActionBarContainer,
ActionBarContent,
ActionBarGroup,
ActionBarPortal,
} from "@rallly/ui/action-bar";
import { Button } from "@rallly/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@rallly/ui/dialog";
import { AnimatePresence, motion } from "framer-motion";
import { TrashIcon } from "lucide-react";
import * as React from "react";
import { deletePolls } from "@/app/[locale]/(admin)/polls/actions";
import { Trans } from "@/components/trans";
import { usePollSelection } from "./context";
const MActionBar = motion(ActionBarContainer);
export function PollSelectionActionBar() {
const { selectedCount, clearSelection, getSelectedPollIds } =
usePollSelection();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false);
const handleDelete = async () => {
const selectedPollIds = getSelectedPollIds();
if (selectedPollIds.length === 0) {
return;
}
setIsDeleting(true);
try {
const result = await deletePolls(selectedPollIds);
if (result.success) {
setIsDeleteDialogOpen(false);
clearSelection();
} else {
// Handle error case
console.error("Failed to delete polls:", result.error);
}
} finally {
setIsDeleting(false);
}
};
return (
<ActionBarPortal>
<AnimatePresence>
{selectedCount > 0 && (
<MActionBar
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.5,
}}
>
<ActionBarContent>
<span className="text-sm font-medium">
<Trans
i18nKey="selectedPolls"
defaults="{count} {count, plural, one {poll} other {polls}} selected"
values={{ count: selectedCount }}
/>
</span>
</ActionBarContent>
<ActionBarGroup>
<Button
variant="actionBar"
onClick={clearSelection}
className="text-action-bar-foreground"
>
<Trans i18nKey="unselectAll" defaults="Unselect All" />
</Button>
<Button
variant="destructive"
onClick={() => setIsDeleteDialogOpen(true)}
>
<TrashIcon className="size-4" />
<Trans i18nKey="delete" defaults="Delete" />
</Button>
</ActionBarGroup>
</MActionBar>
)}
</AnimatePresence>
{/* Delete Polls Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>
<Trans i18nKey="deletePolls" defaults="Delete Polls" />
</DialogTitle>
</DialogHeader>
<p className="text-sm">
{selectedCount === 1 ? (
<Trans
i18nKey="deletePollDescription"
defaults="Are you sure you want to delete this poll? This action cannot be undone."
/>
) : (
<Trans
i18nKey="deletePollsDescription"
defaults="Are you sure you want to delete these {count} polls? This action cannot be undone."
values={{ count: selectedCount }}
/>
)}
</p>
<DialogFooter>
<Button
onClick={() => {
setIsDeleteDialogOpen(false);
}}
>
<Trans i18nKey="cancel" defaults="Cancel" />
</Button>
<Button
variant="destructive"
onClick={handleDelete}
loading={isDeleting}
>
<Trans i18nKey="delete" defaults="Delete" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ActionBarPortal>
);
}

View file

@ -0,0 +1,3 @@
export * from "./timezone-context";
export * from "./timezone-display";
export * from "./timezone-utils";

View file

@ -0,0 +1,104 @@
"use client";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { createContext, useContext, useEffect, useState } from "react";
// Initialize dayjs plugins
dayjs.extend(utc);
dayjs.extend(timezone);
// Default to browser timezone if not specified
const getBrowserTimezone = () => {
if (typeof window !== "undefined") {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
return "UTC"; // Default to UTC for server-side rendering
};
type TimezoneContextType = {
timezone: string;
setTimezone: (timezone: string) => void;
formatDate: (date: string | Date | dayjs.Dayjs, format?: string) => string;
formatTime: (date: string | Date | dayjs.Dayjs, format?: string) => string;
formatDateTime: (
date: string | Date | dayjs.Dayjs,
format?: string,
) => string;
};
const TimezoneContext = createContext<TimezoneContextType | undefined>(
undefined,
);
export const TimezoneProvider = ({
initialTimezone,
children,
}: {
initialTimezone?: string;
children: React.ReactNode;
}) => {
// Initialize with browser timezone, but allow user preference to override
const [timezone, setTimezone] = useState<string>(() => {
if (initialTimezone) {
return initialTimezone;
}
// Try to get from localStorage first (user preference)
if (typeof window !== "undefined") {
const savedTimezone = localStorage.getItem("userTimezone");
if (savedTimezone) {
return savedTimezone;
}
}
return getBrowserTimezone();
});
// Save timezone preference to localStorage when it changes
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem("userTimezone", timezone);
}
}, [timezone]);
// Format functions that automatically use the current timezone
const formatDate = (
date: string | Date | dayjs.Dayjs,
format = "YYYY-MM-DD",
) => {
return dayjs(date).tz(timezone).format(format);
};
const formatTime = (date: string | Date | dayjs.Dayjs, format = "HH:mm") => {
return dayjs(date).tz(timezone).format(format);
};
const formatDateTime = (
date: string | Date | dayjs.Dayjs,
format = "YYYY-MM-DD HH:mm",
) => {
return dayjs(date).tz(timezone).format(format);
};
const value = {
timezone,
setTimezone,
formatDate,
formatTime,
formatDateTime,
};
return (
<TimezoneContext.Provider value={value}>
{children}
</TimezoneContext.Provider>
);
};
export const useTimezone = () => {
const context = useContext(TimezoneContext);
if (context === undefined) {
throw new Error("useTimezone must be used within a TimezoneProvider");
}
return context;
};

View file

@ -0,0 +1,31 @@
"use client";
import type dayjs from "dayjs";
import { useTimezone } from "./timezone-context";
type DateDisplayProps = {
date: string | Date | dayjs.Dayjs;
format?: string;
};
export function DateDisplay({ date, format = "LL" }: DateDisplayProps) {
const { formatDate } = useTimezone();
return <span>{formatDate(date, format)}</span>;
}
export function TimeDisplay({ date, format = "HH:mm" }: DateDisplayProps) {
const { formatTime } = useTimezone();
return <span>{formatTime(date, format)}</span>;
}
export function DateTimeDisplay({ date, format = "LL, LT" }: DateDisplayProps) {
const { formatDateTime } = useTimezone();
return <span>{formatDateTime(date, format)}</span>;
}
// Component to display the current timezone
export function CurrentTimezone() {
const { timezone } = useTimezone();
return <span>{timezone}</span>;
}

View file

@ -0,0 +1,110 @@
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
// Initialize dayjs plugins
dayjs.extend(utc);
dayjs.extend(timezone);
/**
* Get the browser's timezone
*/
export const getBrowserTimezone = (): string => {
if (typeof window !== "undefined") {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
return "UTC";
};
/**
* Get a list of all available timezones
*/
export const getAllTimezones = (): string[] => {
// This is a simplified list - in a real implementation, you might want to use a more comprehensive list
return [
"UTC",
"Africa/Cairo",
"Africa/Johannesburg",
"Africa/Lagos",
"America/Anchorage",
"America/Bogota",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"America/Mexico_City",
"America/New_York",
"America/Phoenix",
"America/Sao_Paulo",
"America/Toronto",
"Asia/Bangkok",
"Asia/Dubai",
"Asia/Hong_Kong",
"Asia/Jakarta",
"Asia/Kolkata",
"Asia/Seoul",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Tokyo",
"Australia/Melbourne",
"Australia/Perth",
"Australia/Sydney",
"Europe/Amsterdam",
"Europe/Berlin",
"Europe/Istanbul",
"Europe/London",
"Europe/Madrid",
"Europe/Moscow",
"Europe/Paris",
"Europe/Rome",
"Pacific/Auckland",
"Pacific/Honolulu",
];
};
/**
* Convert a date from one timezone to another
*/
export const convertTimezone = (
date: string | Date | dayjs.Dayjs,
fromTimezone: string,
toTimezone: string,
): dayjs.Dayjs => {
return dayjs(date).tz(fromTimezone).tz(toTimezone);
};
/**
* Format a date for display with timezone
*/
export const formatWithTimezone = (
date: string | Date | dayjs.Dayjs,
timezone: string,
format: string,
): string => {
return dayjs(date).tz(timezone).format(format);
};
/**
* Get the timezone offset as a string (e.g., "UTC+1:00")
*/
export const getTimezoneOffset = (timezone: string): string => {
const offset = dayjs().tz(timezone).format("Z");
return `UTC${offset}`;
};
/**
* Group timezones by offset
*/
export const groupTimezonesByOffset = (): Record<string, string[]> => {
const timezones = getAllTimezones();
const grouped: Record<string, string[]> = {};
timezones.forEach((tz) => {
const offset = dayjs().tz(tz).format("Z");
if (!grouped[offset]) {
grouped[offset] = [];
}
grouped[offset].push(tz);
});
return grouped;
};