Task #613: replace per-app hide toggles with single Dock visibility switch

The per-app hidden feature shipped in #609 was rejected — user wanted one
toggle that hides/shows the entire bottom AppDock bar, leaving Home apps
untouched.

Removed:
- lib/db/src/schema/user-hidden-apps.ts (and index.ts export); table
  dropped via `pnpm --filter @workspace/db run push`
- GET /me/apps and PUT /me/apps/:appId/hidden routes
- getHiddenAppIdsForUser + getVisibleNonHiddenAppsForUser helpers
- MyApp + UpdateMyAppHiddenBody OpenAPI schemas
- MyAppsBody settings UI + related i18n keys
- Regenerated api-client-react + api-zod from the trimmed spec
- Reverted GET /apps back to getVisibleAppsForUser

Added:
- artifacts/tx-os/src/hooks/use-dock-visible.ts — localStorage-backed
  preference with a custom window event for in-tab sync and the native
  `storage` event for cross-tab sync. Default = true.
- DockBody in settings-panel: single ToggleRow under a new "App dock"
  section ("settingsPanel.section.dock" / "settingsPanel.dock.show")
- AppDock now returns null when the preference is off and clears
  `--app-dock-height` so page padding doesn't stay reserved.

Code review: PASS (architect). No remaining references to the removed
infra. Typecheck shows only pre-existing errors in executive-meetings.ts
and push.ts unrelated to this change.

Follow-up: publish to Gitea + redeploy on Mac (proposed as a follow-up
task).
This commit is contained in:
riyadhafraa
2026-05-19 11:35:13 +00:00
parent f1536cb4db
commit 3f6a0fb02f
13 changed files with 95 additions and 638 deletions
+6 -3
View File
@@ -9,6 +9,7 @@ import {
logAppOpen,
type App,
} from "@workspace/api-client-react";
import { useDockVisible } from "@/hooks/use-dock-visible";
type IconName = keyof typeof LucideIcons;
function isIconName(x: string): x is IconName {
@@ -34,6 +35,7 @@ export function AppDock({ currentSlug }: AppDockProps) {
const lang = i18n.language;
const { data: apps } = useListApps({ query: { queryKey: getListAppsQueryKey() } });
const dockRef = useRef<HTMLDivElement | null>(null);
const [dockEnabled] = useDockVisible();
// Match Home's source-of-truth contract: same `useListApps` query
// key and the same `isActive` filter Home applies in its
@@ -45,9 +47,10 @@ export function AppDock({ currentSlug }: AppDockProps) {
(a) => a.isActive && a.slug !== currentSlug,
);
// Skip the degenerate single-button dock: if the user only has one
// other app to switch to, the dock adds no real value.
const visible = others.length > 1;
// Hide the dock when:
// - the user disabled it in Settings (dockEnabled === false), or
// - it would be degenerate (≤1 other app to switch to).
const visible = dockEnabled && others.length > 1;
// Publish the dock's measured outer height as `--app-dock-height` on
// the document root so the global `body { padding-bottom }` rule in
+22 -108
View File
@@ -4,18 +4,12 @@ import { useQueryClient } from "@tanstack/react-query";
import {
useUpdateLanguage,
getGetMeQueryKey,
useListMyApps,
useUpdateMyAppHidden,
getListMyAppsQueryKey,
getListAppsQueryKey,
type MyApp,
} from "@workspace/api-client-react";
import * as LucideIcons from "lucide-react";
import type { LucideIcon } from "lucide-react";
import {
Settings as SettingsIcon,
Volume2,
VolumeX,
LayoutGrid,
} from "lucide-react";
import {
Sheet,
@@ -41,6 +35,7 @@ import {
} from "@/components/notification-settings";
import { ClockStyleContent } from "@/components/clock-style-picker";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useDockVisible } from "@/hooks/use-dock-visible";
import i18n from "@/i18n";
function ToggleRow({
@@ -141,107 +136,26 @@ function LanguageBody() {
);
}
type IconName = keyof typeof LucideIcons;
function isIconName(x: string): x is IconName {
return x in LucideIcons;
}
function resolveAppIcon(name: string): LucideIcon {
if (isIconName(name)) {
const Candidate = LucideIcons[name] as unknown;
if (typeof Candidate === "function" || typeof Candidate === "object") {
return Candidate as LucideIcon;
}
}
return LucideIcons.Grid2X2;
}
/**
* Per-user Home/Dock app visibility. Each row is a Switch that toggles a
* row in `user_hidden_apps` for the current user. The mutation invalidates
* both this list and the GET /apps query that backs Home + Dock so the
* change reflects everywhere immediately without a reload.
* Single toggle to hide/show the bottom AppDock bar.
* Preference is per-device (localStorage) and the AppDock listens to
* the same hook so the change is instant without a reload.
*/
function MyAppsBody() {
const { t, i18n: i18nInstance } = useTranslation();
const lang = i18nInstance.language;
const queryClient = useQueryClient();
const { data: apps, isLoading } = useListMyApps({
query: { queryKey: getListMyAppsQueryKey() },
});
const update = useUpdateMyAppHidden({
mutation: {
onMutate: async ({ appId, data }) => {
const key = getListMyAppsQueryKey();
await queryClient.cancelQueries({ queryKey: key });
const previous = queryClient.getQueryData<MyApp[]>(key);
if (previous) {
queryClient.setQueryData<MyApp[]>(
key,
previous.map((a) =>
a.id === appId ? { ...a, hidden: data.hidden } : a,
),
);
}
return { previous };
},
onError: (_err, _vars, ctx) => {
if (ctx?.previous) {
queryClient.setQueryData(getListMyAppsQueryKey(), ctx.previous);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: getListMyAppsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() });
},
},
});
if (isLoading) {
return (
<div className="px-3 py-4 text-sm text-muted-foreground">
{t("settingsPanel.myApps.loading")}
</div>
);
}
if (!apps || apps.length === 0) {
return (
<div className="px-3 py-4 text-sm text-muted-foreground">
{t("settingsPanel.myApps.empty")}
</div>
);
}
function DockBody() {
const { t } = useTranslation();
const [visible, setVisible] = useDockVisible();
return (
<div className="flex flex-col gap-0.5">
{apps.map((app) => {
const Icon = resolveAppIcon(app.iconName);
const name = lang === "ar" ? app.nameAr : app.nameEn;
const visible = !app.hidden;
const ariaLabel = visible
? t("settingsPanel.myApps.hide", { name })
: t("settingsPanel.myApps.show", { name });
return (
<ToggleRow
key={app.id}
active={visible}
onClick={() =>
update.mutate({ appId: app.id, data: { hidden: visible } })
}
label={name}
ariaLabel={ariaLabel}
leftIcon={
<span
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
style={{ backgroundColor: `${app.color}20`, color: app.color }}
aria-hidden="true"
>
<Icon size={14} />
</span>
}
/>
);
})}
</div>
<ToggleRow
active={visible}
onClick={() => setVisible(!visible)}
label={t("settingsPanel.dock.show")}
leftIcon={
<LayoutGrid
size={16}
className={visible ? "text-primary shrink-0" : "text-muted-foreground shrink-0"}
/>
}
/>
);
}
@@ -267,7 +181,7 @@ function SoundBody() {
);
}
type GroupId = "myApps" | "language" | "notifications" | "sound" | "clock";
type GroupId = "dock" | "language" | "notifications" | "sound" | "clock";
/**
* Section wrapper that styles each accordion item like the existing
@@ -332,8 +246,8 @@ function SettingsPanelBody({
onValueChange={(v) => onOpenChange(v as GroupId[])}
className="space-y-2"
>
<GroupItem id="myApps" title={t("settingsPanel.section.myApps")}>
<MyAppsBody />
<GroupItem id="dock" title={t("settingsPanel.section.dock")}>
<DockBody />
</GroupItem>
<GroupItem id="language" title={t("settingsPanel.section.language")}>
<LanguageBody />