diff --git a/src/components/app-header.tsx b/src/components/app-header.tsx index c833d87..1326329 100644 --- a/src/components/app-header.tsx +++ b/src/components/app-header.tsx @@ -4,6 +4,7 @@ import { CheckCircle2, CreditCard, Info, + Keyboard, LogOut, Music, PanelLeftClose, @@ -15,11 +16,24 @@ import { import { type ReactNode, useEffect, useState } from "react"; import { GlobalSearch } from "@/components/global-search"; import { JamPresenceSync } from "@/components/jam-presence-sync"; +import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { SpotifyJamCard } from "@/components/spotify-jam-card"; import { Skeleton } from "@/components/ui/skeleton"; import { useToast } from "@/components/ui/toast"; import { useAuth } from "@/lib/auth"; +import { + clickShortcutAction, + focusMainContent, + focusPageSearch, + GLOBAL_SEARCH_SHORTCUT_ARIA, + getPrimaryModifierLabel, + hasOpenBlockingDialog, + isEditableShortcutTarget, + NAVIGATION_SHORTCUTS, + SHORTCUT_HELP_SHORTCUT_ARIA, +} from "@/lib/keyboard-shortcuts"; import { useRPGStore } from "@/lib/store"; +import { useShortcutViewport } from "@/lib/use-shortcut-viewport"; import { cn } from "@/lib/utils"; import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; import { Button, buttonVariants } from "./ui/button"; @@ -88,17 +102,91 @@ export function AppHeader({ isSidebarOpen, onToggleSidebar }: AppHeaderProps) { const isLoading = useRPGStore((state) => state.isLoadingRemote); const [isSearchOpen, setIsSearchOpen] = useState(false); + const [isShortcutsOpen, setIsShortcutsOpen] = useState(false); + const [shortcutAnnouncement, setShortcutAnnouncement] = useState(""); + const supportsShortcuts = useShortcutViewport(); + const primaryModifierLabel = getPrimaryModifierLabel(); useEffect(() => { + if (!supportsShortcuts) { + setIsShortcutsOpen(false); + return; + } + const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "k") { + const key = e.key.toLowerCase(); + const isPrimaryModifier = e.metaKey || e.ctrlKey; + const isDialogOpen = hasOpenBlockingDialog(); + + if ( + isPrimaryModifier && + !e.altKey && + !e.shiftKey && + key === "k" && + !isDialogOpen + ) { e.preventDefault(); setIsSearchOpen(true); + setIsShortcutsOpen(false); + return; + } + + if ( + isPrimaryModifier && + !e.altKey && + !e.shiftKey && + e.key === "/" && + !isDialogOpen + ) { + e.preventDefault(); + setIsShortcutsOpen(true); + setIsSearchOpen(false); + return; + } + + if (isDialogOpen || isEditableShortcutTarget(e.target)) return; + + if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) { + const navigationShortcut = NAVIGATION_SHORTCUTS.find( + (shortcut) => shortcut.key === key, + ); + + if (navigationShortcut) { + e.preventDefault(); + setShortcutAnnouncement(`Navegando para ${navigationShortcut.label}`); + void navigate({ to: navigationShortcut.to }); + return; + } + + if (key === "s") { + e.preventDefault(); + if (focusPageSearch()) { + setShortcutAnnouncement("Filtro da página em foco"); + } else { + setIsSearchOpen(true); + } + return; + } + + if (key === "n") { + e.preventDefault(); + if (clickShortcutAction("new")) { + setShortcutAnnouncement("Ação de novo registro aberta"); + } + return; + } + + if (key === "m") { + e.preventDefault(); + if (focusMainContent()) { + setShortcutAnnouncement("Conteúdo principal em foco"); + } + } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, []); + }, [navigate, supportsShortcuts]); return ( <> @@ -106,11 +194,23 @@ export function AppHeader({ isSidebarOpen, onToggleSidebar }: AppHeaderProps) { {isSearchOpen && ( )} + {supportsShortcuts && isShortcutsOpen && ( + + )} + + {shortcutAnnouncement} +
@@ -149,12 +251,27 @@ export function AppHeader({ isSidebarOpen, onToggleSidebar }: AppHeaderProps) {
+ {supportsShortcuts && ( + + )} @@ -194,6 +311,7 @@ export function AppHeader({ isSidebarOpen, onToggleSidebar }: AppHeaderProps) { buttonVariants({ variant: "ghost", size: "icon" }), "relative h-9 w-9 rounded-xl text-muted-foreground hover:bg-emerald-500/10 hover:text-emerald-400", )} + aria-label="Abrir Spotify Jam" title="Spotify Jam" > @@ -216,6 +334,7 @@ export function AppHeader({ isSidebarOpen, onToggleSidebar }: AppHeaderProps) { className={cn( "flex h-9 w-9 items-center justify-center overflow-hidden rounded-full border border-border/70 transition-[opacity,transform,box-shadow] duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] hover:opacity-90 hover:shadow-md active:scale-[0.97] focus:outline-none", )} + aria-label="Abrir menu da conta" > {isLoading ? ( diff --git a/src/components/campaign-switcher.tsx b/src/components/campaign-switcher.tsx index 7438e98..4b3abcf 100644 --- a/src/components/campaign-switcher.tsx +++ b/src/components/campaign-switcher.tsx @@ -1,9 +1,17 @@ import { Check, ChevronDown, Plus, Trash2 } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRPGStore } from "@/lib/store"; import { cn } from "@/lib/utils"; import { CampaignForm } from "./campaign-form"; import { Button } from "./ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -13,14 +21,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "./ui/dropdown-menu"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "./ui/dialog"; interface CampaignSwitcherProps { isCollapsed: boolean; @@ -55,6 +55,7 @@ export function CampaignSwitcher({ isCollapsed }: CampaignSwitcherProps) { const deleteCampaign = useRPGStore((s) => s.deleteCampaign); const [showForm, setShowForm] = useState(false); const [campaignToDelete, setCampaignToDelete] = useState(null); + const isMobile = useIsMobileSidebar(); const activeCampaign = campaigns.find((c) => c.id === activeCampaignId); const activeIndex = campaigns.findIndex((c) => c.id === activeCampaignId); @@ -69,55 +70,62 @@ export function CampaignSwitcher({ isCollapsed }: CampaignSwitcherProps) { return ( <> - -
= 0 ? activeIndex : 0), - )} - > - {activeCampaign ? campaignInitials(activeCampaign.name) : "??"} -
-
- - {activeCampaign?.name ?? "Sem campanha"} - - - {activeCampaign?.description || "Campanha ativa"} - -
- - - } /> +
= 0 ? activeIndex : 0), + )} + > + {activeCampaign ? campaignInitials(activeCampaign.name) : "??"} +
+
+ + {activeCampaign?.name ?? "Sem campanha"} + + + {activeCampaign?.description || "Campanha ativa"} + +
+ + + } + /> @@ -127,7 +135,6 @@ export function CampaignSwitcher({ isCollapsed }: CampaignSwitcherProps) { { - console.log("Switching to:", campaign.id); void switchCampaign(campaign.id); }} className="group flex items-center justify-between gap-3 py-2.5" @@ -164,7 +171,8 @@ export function CampaignSwitcher({ isCollapsed }: CampaignSwitcherProps) { e.stopPropagation(); setCampaignToDelete(campaign.id); }} - className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100" + aria-label={`Excluir campanha ${campaign.name}`} + className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground opacity-100 transition-opacity hover:bg-destructive/10 hover:text-destructive md:opacity-0 md:group-hover:opacity-100" > @@ -198,22 +206,16 @@ export function CampaignSwitcher({ isCollapsed }: CampaignSwitcherProps) { Excluir Campanha? - Isso removerá apenas o registro da campanha. Os dados associados - a ela no banco de dados não serão deletados, mas ficarão órfãos. + Isso removerá apenas o registro da campanha. Os dados associados a + ela no banco de dados não serão deletados, mas ficarão órfãos. Esta ação não pode ser desfeita. - - @@ -222,3 +224,22 @@ export function CampaignSwitcher({ isCollapsed }: CampaignSwitcherProps) { ); } + +function useIsMobileSidebar() { + const [isMobile, setIsMobile] = useState(() => + typeof window === "undefined" + ? false + : window.matchMedia("(max-width: 767px)").matches, + ); + + useEffect(() => { + const mediaQuery = window.matchMedia("(max-width: 767px)"); + const updateIsMobile = () => setIsMobile(mediaQuery.matches); + + updateIsMobile(); + mediaQuery.addEventListener("change", updateIsMobile); + return () => mediaQuery.removeEventListener("change", updateIsMobile); + }, []); + + return isMobile; +} diff --git a/src/components/character-card.tsx b/src/components/character-card.tsx index 99c6b57..ccda0bd 100644 --- a/src/components/character-card.tsx +++ b/src/components/character-card.tsx @@ -1,6 +1,7 @@ import { Link, useNavigate } from "@tanstack/react-router"; import { Heart, Shield, Sparkles, User, Zap } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; +import { useToast } from "@/components/ui/toast"; import { abilityModifier, type Character, @@ -20,6 +21,7 @@ const abilitySkeletonKeys = ["str", "dex", "con", "int", "wis", "cha"]; export function CharacterCard({ character, isLoading }: CharacterCardProps) { const removeCharacter = useRPGStore((s) => s.removeCharacter); const navigate = useNavigate(); + const toast = useToast(); if (isLoading || !character) { return ( @@ -52,20 +54,35 @@ export function CharacterCard({ character, isLoading }: CharacterCardProps) { ); } - const hpPercent = character.healthMax - ? Math.min(100, Math.round((character.health / character.healthMax) * 100)) + const currentCharacter = character; + const hpPercent = currentCharacter.healthMax + ? Math.min( + 100, + Math.round( + (currentCharacter.health / currentCharacter.healthMax) * 100, + ), + ) : 0; const abilities = [ - { label: "FOR", value: character.strength }, - { label: "DES", value: character.dexterity }, - { label: "CON", value: character.constitution }, - { label: "INT", value: character.intelligence }, - { label: "SAB", value: character.wisdom }, - { label: "CAR", value: character.charisma }, + { label: "FOR", value: currentCharacter.strength }, + { label: "DES", value: currentCharacter.dexterity }, + { label: "CON", value: currentCharacter.constitution }, + { label: "INT", value: currentCharacter.intelligence }, + { label: "SAB", value: currentCharacter.wisdom }, + { label: "CAR", value: currentCharacter.charisma }, ]; - const hasImage = !!character.imageUrl; + const hasImage = !!currentCharacter.imageUrl; + const characterName = currentCharacter.characterName || "Sem nome"; + + function handleDeleteCharacter() { + removeCharacter(currentCharacter.id); + toast.success({ + title: "Personagem excluído com sucesso", + description: `${characterName} foi removido da campanha.`, + }); + } return ( removeCharacter(character.id)} - entityName={character.characterName || "Sem nome"} + onDelete={handleDeleteCharacter} + entityName={characterName} entityKindLabel="personagem" /> diff --git a/src/components/entity-actions.tsx b/src/components/entity-actions.tsx index f6527df..6513dbe 100644 --- a/src/components/entity-actions.tsx +++ b/src/components/entity-actions.tsx @@ -100,7 +100,10 @@ function ConfirmDeleteDialog({ }: ConfirmDeleteDialogProps) { return ( - + event.stopPropagation()} + > {title} {description} @@ -110,7 +113,11 @@ function ConfirmDeleteDialog({ variant="ghost" size="sm" type="button" - onClick={() => onOpenChange(false)} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + onOpenChange(false); + }} > Cancelar @@ -118,7 +125,11 @@ function ConfirmDeleteDialog({ variant="destructive" size="sm" type="button" - onClick={onConfirm} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + onConfirm(); + }} > Excluir diff --git a/src/components/global-search.tsx b/src/components/global-search.tsx index e9f0ed1..ff74ffb 100644 --- a/src/components/global-search.tsx +++ b/src/components/global-search.tsx @@ -13,12 +13,18 @@ import { useCallback, useDeferredValue, useEffect, + useId, useMemo, useState, } from "react"; import { useRPGStore } from "@/lib/store"; import { cn } from "@/lib/utils"; -import { Dialog, DialogContent } from "./ui/dialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "./ui/dialog"; interface GlobalSearchProps { open: boolean; @@ -37,6 +43,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) { const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); const [selectedIndex, setSelectedIndex] = useState(0); + const resultsId = useId(); const characters = useRPGStore((s) => s.characters); const npcs = useRPGStore((s) => s.npcs); @@ -173,10 +180,24 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) { showCloseButton={false} className="max-w-2xl overflow-hidden p-0 shadow-2xl" > + Pesquisa global + + Pesquise e abra registros da campanha. +
0} + aria-autocomplete="list" + aria-activedescendant={ + results[selectedIndex] + ? `${resultsId}-${results[selectedIndex].type}-${results[selectedIndex].id}` + : undefined + } className="ml-3 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground" placeholder="Pesquisar personagens, itens, lores..." value={query} @@ -192,7 +213,12 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
-
+
{query.trim() === "" ? (
@@ -213,7 +239,11 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) { diff --git a/src/components/keyboard-shortcuts-dialog.tsx b/src/components/keyboard-shortcuts-dialog.tsx new file mode 100644 index 0000000..5d8a749 --- /dev/null +++ b/src/components/keyboard-shortcuts-dialog.tsx @@ -0,0 +1,102 @@ +import { Keyboard } from "lucide-react"; +import { + getPrimaryModifierLabel, + NAVIGATION_SHORTCUTS, +} from "@/lib/keyboard-shortcuts"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; + +interface KeyboardShortcutsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function KeyboardShortcutsDialog({ + open, + onOpenChange, +}: KeyboardShortcutsDialogProps) { + const primaryModifier = getPrimaryModifierLabel(); + const generalShortcuts = [ + { keys: [primaryModifier, "K"], label: "Pesquisa global" }, + { keys: [primaryModifier, "/"], label: "Abrir atalhos" }, + { keys: ["Alt", "S"], label: "Filtrar lista atual" }, + { keys: ["Alt", "N"], label: "Novo registro da página" }, + { keys: ["Alt", "M"], label: "Ir para o conteúdo principal" }, + ]; + + return ( + + + +
+
+ +
+
+ + Atalhos de teclado + + + Comandos rápidos para navegar sem tirar as mãos do teclado. + +
+
+
+ +
+ + ({ + keys: ["Alt", shortcut.key], + label: shortcut.label, + }))} + /> +
+
+
+ ); +} + +function ShortcutGroup({ + title, + shortcuts, +}: { + title: string; + shortcuts: Array<{ keys: string[]; label: string }>; +}) { + return ( +
+

+ {title} +

+
+ {shortcuts.map((shortcut) => ( +
+ + {shortcut.label} + +
+ {shortcut.keys.map((key) => ( + + {key} + + ))} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/list-layout.tsx b/src/components/list-layout.tsx index f1027b8..910f034 100644 --- a/src/components/list-layout.tsx +++ b/src/components/list-layout.tsx @@ -1,4 +1,6 @@ import { Filter, Search } from "lucide-react"; +import { PAGE_SEARCH_SHORTCUT_ARIA } from "@/lib/keyboard-shortcuts"; +import { useShortcutViewport } from "@/lib/use-shortcut-viewport"; interface ListLayoutProps { onSearch?: (query: string) => void; @@ -6,18 +8,25 @@ interface ListLayoutProps { } export function ListLayout({ onSearch }: ListLayoutProps) { + const supportsShortcuts = useShortcutViewport(); + return (
onSearch?.(e.target.value)} className="h-10 w-full rounded-2xl border border-border/70 bg-background/55 pl-9 pr-12 text-[13px] text-foreground shadow-sm shadow-black/5 transition-[border-color,box-shadow,background-color] duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] placeholder:text-muted-foreground/70 hover:border-border-hover/70 focus:border-primary/45 focus:bg-background/75 focus:outline-none focus:ring-2 focus:ring-primary/15" /> - - ⌘K + + Alt S
diff --git a/src/components/location-form.tsx b/src/components/location-form.tsx index a13d0e0..8d9fcbf 100644 --- a/src/components/location-form.tsx +++ b/src/components/location-form.tsx @@ -31,7 +31,12 @@ export function LocationForm() { return ( + @@ -149,7 +154,11 @@ function LocationFormDialog({ size="md" />
- + + @@ -135,7 +140,11 @@ function LoreFormDialog({ size="md" />
- + + diff --git a/src/components/session-form.tsx b/src/components/session-form.tsx index a6fc7cf..c0e32a4 100644 --- a/src/components/session-form.tsx +++ b/src/components/session-form.tsx @@ -31,7 +31,12 @@ export function SessionForm() { return ( + diff --git a/src/components/sidebar-nav.tsx b/src/components/sidebar-nav.tsx index 2cad24c..d126ea4 100644 --- a/src/components/sidebar-nav.tsx +++ b/src/components/sidebar-nav.tsx @@ -9,6 +9,7 @@ interface SidebarNavProps { label: string; Icon: LucideIcon; exact?: boolean; + shortcut?: string; }[]; onItemClick?: () => void; } @@ -33,10 +34,11 @@ export function SidebarNav({
)}
- {items.map(({ to, label, Icon, exact }) => ( + {items.map(({ to, label, Icon, exact, shortcut }) => ( ; @@ -22,16 +21,19 @@ function DropdownMenuContent({ side = "bottom", sideOffset = 4, className, + positionerClassName, ...props }: MenuPrimitive.Popup.Props & Pick< MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" - >) { + > & { + positionerClassName?: string; + }) { return ( ( + "[data-shortcut-target='page-search']", + ); + + if (!searchInput || searchInput.disabled) return false; + searchInput.focus(); + searchInput.select(); + return true; +} + +export function clickShortcutAction(action: "new") { + const element = document.querySelector( + `[data-shortcut-action='${action}']`, + ); + + if (!element) return false; + if (element instanceof HTMLButtonElement && element.disabled) return false; + element.click(); + return true; +} + +export function focusMainContent() { + const main = document.querySelector( + "[data-shortcut-target='main-content']", + ); + if (!(main instanceof HTMLElement)) return false; + main.focus(); + return true; +} diff --git a/src/lib/spotify.ts b/src/lib/spotify.ts index be89887..e823346 100644 --- a/src/lib/spotify.ts +++ b/src/lib/spotify.ts @@ -4,6 +4,7 @@ const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID || ""; const CONFIGURED_REDIRECT_URI = import.meta.env.VITE_SPOTIFY_REDIRECT_URI || ""; +const SPOTIFY_REDIRECT_URI_STORAGE_KEY = "spotify_redirect_uri"; function getSpotifyRedirectUri() { if (typeof window === "undefined") { @@ -11,15 +12,15 @@ function getSpotifyRedirectUri() { } const currentOriginRedirect = `${window.location.origin}/spotify-callback`; - if (CONFIGURED_REDIRECT_URI) { - return CONFIGURED_REDIRECT_URI; - } - const isLocalDev = window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"; - return isLocalDev ? currentOriginRedirect : "/spotify-callback"; + if (isLocalDev) { + return currentOriginRedirect; + } + + return CONFIGURED_REDIRECT_URI || currentOriginRedirect; } /** @@ -73,6 +74,8 @@ export async function redirectToSpotifyAuth() { const authUrl = new URL("https://accounts.spotify.com/authorize"); const redirectUri = getSpotifyRedirectUri(); + window.localStorage.setItem(SPOTIFY_REDIRECT_URI_STORAGE_KEY, redirectUri); + const params = { response_type: "code", client_id: SPOTIFY_CLIENT_ID, @@ -92,7 +95,9 @@ export async function redirectToSpotifyAuth() { */ export async function getSpotifyToken(code: string): Promise { const verifier = window.localStorage.getItem("spotify_code_verifier"); - const redirectUri = getSpotifyRedirectUri(); + const redirectUri = + window.localStorage.getItem(SPOTIFY_REDIRECT_URI_STORAGE_KEY) || + getSpotifyRedirectUri(); const params = new URLSearchParams({ client_id: SPOTIFY_CLIENT_ID, @@ -141,9 +146,11 @@ export async function getUserProfile(accessToken: string) { } catch (e) { errorDetails = "Não foi possível ler o corpo do erro."; } - + console.error(`Erro Spotify (${response.status}):`, errorDetails); - throw new Error(`Erro ao buscar perfil (${response.status}): ${errorDetails}`); + throw new Error( + `Erro ao buscar perfil (${response.status}): ${errorDetails}`, + ); } return response.json(); diff --git a/src/lib/use-shortcut-viewport.ts b/src/lib/use-shortcut-viewport.ts new file mode 100644 index 0000000..bfa31c7 --- /dev/null +++ b/src/lib/use-shortcut-viewport.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +const SHORTCUT_VIEWPORT_QUERY = "(min-width: 768px)"; + +export function useShortcutViewport() { + const [supportsShortcuts, setSupportsShortcuts] = useState(() => + typeof window === "undefined" + ? true + : window.matchMedia(SHORTCUT_VIEWPORT_QUERY).matches, + ); + + useEffect(() => { + const mediaQuery = window.matchMedia(SHORTCUT_VIEWPORT_QUERY); + const updateSupportsShortcuts = () => + setSupportsShortcuts(mediaQuery.matches); + + updateSupportsShortcuts(); + mediaQuery.addEventListener("change", updateSupportsShortcuts); + return () => + mediaQuery.removeEventListener("change", updateSupportsShortcuts); + }, []); + + return supportsShortcuts; +} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 80f8616..6b36c62 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -58,6 +58,7 @@ function RootContent() { function ProtectedLayout() { const navigate = useNavigate(); + const mainContentId = useId(); const { loading, session, plan } = useAuth(); const loadRemoteData = useRPGStore((s) => s.loadRemoteData); const setupRealtime = useRPGStore((s) => s.setupRealtime); @@ -139,6 +140,12 @@ function ProtectedLayout() { return (
+ + Pular para o conteúdo + {/* Mobile Sidebar Overlay */} {isSidebarOpen && (