3f6a0fb02f
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).
147 lines
5.4 KiB
TypeScript
147 lines
5.4 KiB
TypeScript
import { useEffect, useLayoutEffect, useRef } from "react";
|
|
import { useLocation } from "wouter";
|
|
import { useTranslation } from "react-i18next";
|
|
import * as LucideIcons from "lucide-react";
|
|
import type { LucideIcon } from "lucide-react";
|
|
import {
|
|
useListApps,
|
|
getListAppsQueryKey,
|
|
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 {
|
|
return x in LucideIcons;
|
|
}
|
|
function resolveIcon(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;
|
|
}
|
|
|
|
export interface AppDockProps {
|
|
currentSlug: string;
|
|
}
|
|
|
|
export function AppDock({ currentSlug }: AppDockProps) {
|
|
const [, setLocation] = useLocation();
|
|
const { i18n } = useTranslation();
|
|
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
|
|
// orderedApps effect (home.tsx L365). We don't subscribe to Home's
|
|
// local drag-reorder state here because that state is intentionally
|
|
// a Home-only UX surface; the dock just mirrors the API order, same
|
|
// as Home's `orderedApps ?? apps` fallback on first render.
|
|
const others = (apps ?? []).filter(
|
|
(a) => a.isActive && a.slug !== currentSlug,
|
|
);
|
|
|
|
// 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
|
|
// index.css can reserve space below page content. Without this, the
|
|
// floating dock (position: fixed) overlaps the last row on long
|
|
// mobile pages (e.g. Meetings, Notes, My Orders).
|
|
//
|
|
// We re-measure on resize and on `others.length` changes so the
|
|
// padding stays in sync as the dock grows/shrinks or the safe-area
|
|
// inset changes (iOS rotation, URL-bar collapse).
|
|
useLayoutEffect(() => {
|
|
if (!visible) return;
|
|
const root = document.documentElement;
|
|
const el = dockRef.current;
|
|
if (!el) return;
|
|
|
|
const update = () => {
|
|
// Use getBoundingClientRect so we capture the actual rendered
|
|
// height including the `bottom-2/3` offset (we want the total
|
|
// space the dock occupies from the bottom of the viewport).
|
|
const rect = el.getBoundingClientRect();
|
|
const viewportH = window.innerHeight;
|
|
const occupied = Math.max(0, viewportH - rect.top);
|
|
// A small breathing gap so the last row doesn't kiss the dock.
|
|
root.style.setProperty("--app-dock-height", `${Math.ceil(occupied) + 8}px`);
|
|
};
|
|
|
|
update();
|
|
const ro = new ResizeObserver(update);
|
|
ro.observe(el);
|
|
window.addEventListener("resize", update);
|
|
window.addEventListener("orientationchange", update);
|
|
return () => {
|
|
ro.disconnect();
|
|
window.removeEventListener("resize", update);
|
|
window.removeEventListener("orientationchange", update);
|
|
root.style.removeProperty("--app-dock-height");
|
|
};
|
|
}, [visible, others.length]);
|
|
|
|
// When the dock is hidden, ensure we clear any stale variable so the
|
|
// page doesn't keep extra padding from a prior render.
|
|
useEffect(() => {
|
|
if (visible) return;
|
|
document.documentElement.style.removeProperty("--app-dock-height");
|
|
}, [visible]);
|
|
|
|
if (!visible) return null;
|
|
|
|
const openApp = (app: App) => {
|
|
logAppOpen(app.id).catch(() => {});
|
|
const mode = app.openMode ?? "internal";
|
|
if (mode === "external_tab" && app.externalUrl) {
|
|
window.open(app.externalUrl, "_blank", "noopener,noreferrer");
|
|
return;
|
|
}
|
|
if (mode === "external_iframe" && app.externalUrl) {
|
|
setLocation(`/embedded/${app.id}`);
|
|
return;
|
|
}
|
|
setLocation(app.route);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={dockRef}
|
|
className="fixed inset-x-0 bottom-2 sm:bottom-3 z-40 flex justify-center print:hidden pointer-events-none"
|
|
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
|
|
data-testid="app-dock"
|
|
>
|
|
<div className="pointer-events-auto flex items-center gap-3 sm:gap-4 px-3 sm:px-4 py-2 rounded-full bg-white/95 backdrop-blur border border-gray-200 shadow-lg max-w-[min(92vw,640px)] overflow-x-auto no-scrollbar">
|
|
{others.map((app) => {
|
|
const Icon = resolveIcon(app.iconName);
|
|
const label = lang === "ar" ? app.nameAr : app.nameEn;
|
|
return (
|
|
<button
|
|
key={app.id}
|
|
type="button"
|
|
onClick={() => openApp(app)}
|
|
aria-label={label}
|
|
title={label}
|
|
data-testid={`app-dock-${app.slug}`}
|
|
className="shrink-0 w-11 h-11 rounded-full flex items-center justify-center hover:scale-110 transition-transform"
|
|
style={{ backgroundColor: `${app.color}25` }}
|
|
>
|
|
<Icon size={20} color={app.color} />
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|