Layout reaches zero. The dock, header, user menu, theme switcher, spotlight, shortcuts modal, screen-error fallback, rescan and bug report. Three things worth knowing: CORE_DOCK_ITEMS now carries labelKey instead of label, and one useDockItems() hook resolves the composed list. DashboardLayout, Spotlight and DockSettings each spread the same two sources before, so each would have had to resolve labels itself. Plugin tiles keep the name from their own manifest — a plugin's strings are not ours. Spotlight resolves labels INSIDE the hook, so rank() still matches what the reader can see rather than a dot-path. Its to: '/settings/...' literals are untouched, which is what docs:check parses — verified still 20 offered, 0 missing. The rescan toast said 'N skill(s)'. That is English giving up on a plural, so it is now a real plural entry per noun and reads correctly at 0, 1 and 2 in both locales. ThemeSwitcher used t as its theme loop variable; renamed to entry.
238 lines
12 KiB
TypeScript
238 lines
12 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { Check, Star } from 'lucide-react';
|
|
import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
|
|
import * as Dropdown from '@/components/ui/dropdown-menu';
|
|
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
|
|
import { useThemeFavorites } from '@/state/useThemeFavorites';
|
|
import { useTranslation } from '@/lib/i18n';
|
|
|
|
const {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} = Dropdown;
|
|
|
|
/**
|
|
* Themes, out of the avatar menu and into the header.
|
|
*
|
|
* They were buried under an avatar next to "Sign out", which is where a preference goes when nobody has
|
|
* decided it matters. With fourteen of them it is a thing you use, so it gets its own control.
|
|
*
|
|
* ── The overlay ──
|
|
*
|
|
* `ctrl+shift+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
|
|
* focused theme to the app BEHIND it. That is the whole idea, and it is why this is a portal over a
|
|
* translucent backdrop rather than a modal dialog: you are meant to watch the application change while
|
|
* you browse, so as little of it as possible should be covered.
|
|
*
|
|
* Ported from the owner's `vimazing`, keys and all.
|
|
*/
|
|
export const ThemeSwitcher = () => {
|
|
const { theme, themeId, setTheme, themes } = useTheme();
|
|
const { t } = useTranslation();
|
|
const { favorites, isFavorite, toggle } = useThemeFavorites();
|
|
// Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere.
|
|
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle });
|
|
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
|
|
|
/*
|
|
One ordering, used by both lists.
|
|
|
|
ROTATED to the theme you are wearing rather than sorted: with 200+ installed, the list's only structure
|
|
is the registry order — families sit together in it — and lifting the selected theme out of its family
|
|
would answer "which am I on" by destroying "what is next to it". A rotation answers both. See
|
|
`theme/rotate.ts`.
|
|
|
|
Then favourites first, which is a second rotation-preserving pass rather than a filter: each half keeps
|
|
the order it was given, so the theme you are wearing still leads.
|
|
*/
|
|
const ordered = useMemo(() => {
|
|
const rotated = rotateToSelected(themes, themeId);
|
|
const listed = onlyFavorites ? rotated.filter((entry) => favorites.has(entry.id)) : rotated;
|
|
return favouritesFirst(listed, favorites);
|
|
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
|
|
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
|
|
|
|
const families = useMemo(() => groupByFamily(ordered), [ordered]);
|
|
|
|
return (
|
|
<>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
title={t('theme.pillTitle', {
|
|
name: theme.name,
|
|
starAction: isFavorite(themeId) ? t('theme.unstar') : t('theme.star'),
|
|
})}
|
|
aria-label={t('theme.pillLabel', { name: theme.name })}
|
|
// Named rather than a palette glyph: with 227 themes the one you are wearing is worth
|
|
// reading at a glance, and an icon cannot say which that is. Same pill as JobsIndicator so
|
|
// the cluster stays one shape.
|
|
className="flex h-8 cursor-pointer items-center rounded-full bg-black/25 px-2.5 text-xs font-semibold transition-colors hover:bg-black/35"
|
|
style={{ color: 'var(--header-fg)' }}
|
|
>
|
|
{theme.name}
|
|
{/* A star on the pill, so "is this one of mine" is answerable without opening anything. Only
|
|
when true — a hollow star on every theme would be chrome that says nothing 200 times. */}
|
|
{isFavorite(themeId) && <Star className="ml-1.5 h-3 w-3 fill-current opacity-80" />}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
{/*
|
|
The CONTENT does not scroll — the list inside it does.
|
|
|
|
It used to carry `overflow-y-auto` itself, which scrolled the instruction line away with the
|
|
themes. Two hundred entries meant the one line telling you the keyboard shortcuts existed was
|
|
visible for the first half-second and then gone for good, which is the same as not having written
|
|
it. Header and footer are now siblings of a scroll box rather than passengers in one.
|
|
*/}
|
|
<DropdownMenuContent align="end" className="z-50 flex max-h-[70vh] w-64 flex-col overflow-hidden p-0">
|
|
<div className="shrink-0 border-b border-border px-2 py-1.5">
|
|
<DropdownMenuLabel className="px-0 py-0 text-xs font-normal leading-relaxed text-muted-foreground">
|
|
<span className="block">{t('theme.cycleHint')}</span>
|
|
<span className="block">{t('theme.everyShortcutHint')}</span>
|
|
</DropdownMenuLabel>
|
|
<button
|
|
type="button"
|
|
onClick={(ev) => {
|
|
// The dropdown closes on any item activation, and this is a filter rather than a choice.
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
setOnlyFavorites((was) => !was);
|
|
}}
|
|
className={`mt-1 inline-flex cursor-pointer items-center gap-1.5 rounded px-1.5 py-0.5 text-xs font-medium transition-colors ${
|
|
onlyFavorites ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
<Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} />
|
|
{onlyFavorites
|
|
? t('theme.favouritesOnly', { count: favorites.size })
|
|
: t('theme.favourites', { count: favorites.size })}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-1">
|
|
{ordered.length === 0 ? (
|
|
<p className="px-2 py-6 text-center text-xs text-muted-foreground">{t('theme.noFavourites')}</p>
|
|
) : (
|
|
families.map(([family, entries]) => (
|
|
<div key={family}>
|
|
{entries.map((entry) => (
|
|
<DropdownMenuItem key={entry.id} onClick={() => setTheme(entry.id)} className="cursor-pointer pr-1">
|
|
<Check
|
|
className={`mr-2 h-4 w-4 shrink-0 ${entry.id === themeId ? 'opacity-100' : 'opacity-0'}`}
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
|
{/* A sibling of the row's action, never nested inside it — starring is not choosing. */}
|
|
<button
|
|
type="button"
|
|
aria-label={
|
|
isFavorite(entry.id)
|
|
? t('theme.unstarNamed', { name: entry.name })
|
|
: t('theme.starNamed', { name: entry.name })
|
|
}
|
|
title={isFavorite(entry.id) ? t('theme.removeFromFavourites') : t('theme.addToFavourites')}
|
|
onClick={(ev) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
toggle(entry.id);
|
|
}}
|
|
className="ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-primary"
|
|
>
|
|
<Star className={`h-3.5 w-3.5 ${isFavorite(entry.id) ? 'fill-current text-primary' : ''}`} />
|
|
</button>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{open &&
|
|
createPortal(
|
|
<div
|
|
className="fixed inset-0 z-[900] flex items-center justify-center"
|
|
// Deliberately light: the point is to see the app change behind it.
|
|
style={{ backgroundColor: 'color-mix(in srgb, var(--background) 55%, transparent)' }}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
<div
|
|
className="rounded-2xl border px-10 py-8 shadow-2xl"
|
|
style={{
|
|
backgroundColor: 'var(--popover)',
|
|
borderColor: 'var(--border)',
|
|
color: 'var(--popover-foreground)',
|
|
}}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
>
|
|
{/* The SAME `ordered` list as the dropdown — rotated to what you are wearing, favourites
|
|
first. Both lists agreeing is the point: "the one below the one I have on" has to mean the
|
|
same thing in both, or the browse teaches you an order the menu then contradicts. */}
|
|
<ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
|
|
{ordered.map((entry) => {
|
|
const isFocused = entry.id === focused;
|
|
return (
|
|
<li key={entry.id}>
|
|
<button
|
|
onMouseEnter={() => setFocused(entry.id)}
|
|
onClick={() => {
|
|
setTheme(entry.id);
|
|
setOpen(false);
|
|
}}
|
|
className={`flex w-full items-center gap-2 rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
|
|
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
|
|
}`}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
|
{entry.id === themeId && (
|
|
<span className="shrink-0 text-xs opacity-70">{t('theme.active')}</span>
|
|
)}
|
|
{/*
|
|
A span, not a button: this sits inside the row's own <button>, and a button inside
|
|
a button is invalid HTML that browsers resolve by dropping one of them. The click
|
|
handler and the role are set explicitly instead, and `f` does the same thing from
|
|
the keyboard.
|
|
*/}
|
|
<span
|
|
role="button"
|
|
tabIndex={-1}
|
|
aria-label={
|
|
isFavorite(entry.id)
|
|
? t('theme.unstarNamed', { name: entry.name })
|
|
: t('theme.starNamed', { name: entry.name })
|
|
}
|
|
title={
|
|
isFavorite(entry.id) ? t('theme.removeFromFavouritesKey') : t('theme.addToFavouritesKey')
|
|
}
|
|
onClick={(ev) => {
|
|
// Or the row's own onClick applies the theme and closes the overlay, which is
|
|
// the opposite of what starring is for.
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
toggle(entry.id);
|
|
}}
|
|
className="shrink-0 rounded p-1 opacity-70 hover:opacity-100"
|
|
>
|
|
<Star className={`h-4 w-4 ${isFavorite(entry.id) ? 'fill-current' : ''}`} />
|
|
</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
{/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's
|
|
header does. */}
|
|
<p className="mt-6 text-center text-xs opacity-60">{t('theme.browseHint')}</p>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</>
|
|
);
|
|
};
|