themes: rotate to the one you are wearing, star the ones you like
Four changes for 200+ themes, and one mistake worth recording. ORDERING IS A ROTATION, not a sort. The registry order is the list's only structure — families sit together in it — so lifting the selected theme to the top answers "which am I on" by destroying "what is next to it". Rotating keeps every neighbour and moves the starting point. Both lists use the same `ordered` array, which is the point: "the one below the one I have on" has to mean the same thing in the dropdown and in the ctrl+shift+space browser, or one teaches an order the other contradicts. FAVOURITES are a table, not a JSON column. theme_favorites is a row per star, so a toggle is an INSERT or a DELETE and cannot race with itself; an array in user_settings would be read-modify-written and two tabs would lose one. No foreign key to a theme, because themes are not rows — most are compiled in, the rest are directories discovered at request time — so a star can outlive its theme and the reader intersects with the live registry rather than trusting the table. Favourites-first is a second rotation-preserving pass rather than a filter, so the list can still answer "what else is there". Filtering to favourites only is a separate, explicit toggle. THE INSTRUCTIONS NO LONGER SCROLL AWAY. DropdownMenuContent carried overflow-y-auto itself, so the one line telling you the shortcuts exist was visible for half a second and then gone under two hundred entries — which is the same as not having written it. Header and footer are now siblings of a scroll box instead of passengers in it, and the header names alt+space too. THE MISTAKE: the routes went on /themes first, and that prefix is deliberately UNAUTHENTICATED — the sign-in screen paints in the chosen theme, before any account exists to ask. An anonymous GET answered 500, which was the consequence arriving politely. They now live on /theme-favorites, claimed by the `account` permission, which is core and already owns /user, /dock and /api-keys: a starred theme is the same shape as a dock preference. I then mounted it one line off, in the unprotected block beside the thing I was moving it away from, and caught that by reading rather than by testing. Verified after: /api/theme-favorites is 401 unauthenticated on both verbs, and /api/themes still answers 200 so the login screen keeps its colours. 10 tests on the pure ordering — including the property that makes a rotation a rotation: every adjacency in the original survives it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
/*
|
||||
Starred themes, per account.
|
||||
|
||||
With 200+ installed, "which of these did I like" is a question the list cannot answer by itself, and the
|
||||
answer belongs to the account rather than to the browser — the same taste follows you to the phone.
|
||||
|
||||
Optimistic, and that is not a nicety here: the star sits in a dropdown you are about to close, so a
|
||||
round trip before the icon fills means the confirmation arrives after the thing it confirms has gone.
|
||||
*/
|
||||
|
||||
const KEY = ['themes', 'favorites'];
|
||||
|
||||
export function useThemeFavorites() {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery<{ favorites: string[] }>({
|
||||
queryKey: KEY,
|
||||
queryFn: () => client.get<{ favorites: string[] }>('/theme-favorites'),
|
||||
// Taste does not change while you look at it, and the mutation below keeps the cache honest.
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: ({ themeId, favorite }: { themeId: string; favorite: boolean }) =>
|
||||
client.put<{ themeId: string; favorite: boolean }>(`/theme-favorites/${themeId}`, { favorite }),
|
||||
onMutate: async ({ themeId, favorite }) => {
|
||||
await queryClient.cancelQueries({ queryKey: KEY });
|
||||
const previous = queryClient.getQueryData<{ favorites: string[] }>(KEY);
|
||||
const current = previous?.favorites ?? [];
|
||||
queryClient.setQueryData(KEY, {
|
||||
favorites: favorite ? [...new Set([...current, themeId])] : current.filter((id) => id !== themeId),
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
// Put it back on failure. A star that stayed filled after the write was refused is worse than one that
|
||||
// never filled: it reports a preference the server does not hold.
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(KEY, context.previous);
|
||||
},
|
||||
});
|
||||
|
||||
const favorites = new Set(query.data?.favorites ?? []);
|
||||
|
||||
return {
|
||||
favorites,
|
||||
isFavorite: (themeId: string) => favorites.has(themeId),
|
||||
toggle: (themeId: string) => toggle.mutate({ themeId, favorite: !favorites.has(themeId) }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user