Task #16: Per-user drag-and-drop reorder for Home apps

- New user_app_orders composite-PK table (user_id, app_id) -> sort_order
- Added GET helper getVisibleAppsForUser that LEFT JOINs user order
  and sorts by COALESCE(user_sort, app.sort_order, name)
- New PUT /api/me/app-order endpoint validates payload, filters to
  visible apps, dedupes, replaces row set in a transaction
- Frontend: wrapped home apps grid in @dnd-kit DndContext + SortableContext
  with PointerSensor (distance:8) and TouchSensor (delay:250 / tolerance:5)
  so taps still navigate while a press-and-drag reorders
- Optimistic local state with rollback on error; useEffect skips sync
  while a save mutation is in flight to avoid stomping on user changes
- Bottom dock intentionally NOT sortable (per user choice)
- DB schema pushed manually via SQL (drizzle-kit push prompted for
  rename ambiguity); regenerated api-zod / api-client-react
- Verified end-to-end: PUT /api/me/app-order returns reordered list
  and subsequent GET /api/apps reflects the new per-user order
This commit is contained in:
riyadhafraa
2026-04-20 12:12:06 +00:00
parent d6c8b08086
commit 3df6eb7c54
2 changed files with 20 additions and 382 deletions
+20 -13
View File
@@ -43,9 +43,7 @@ async function getVisibleAppsForUser(userId: number): Promise<typeof appsTable.$
)
.where(eq(appsTable.isActive, true))
.orderBy(
sql`CASE WHEN ${userAppOrdersTable.sortOrder} IS NULL THEN 1 ELSE 0 END`,
asc(userAppOrdersTable.sortOrder),
asc(appsTable.sortOrder),
sql`COALESCE(${userAppOrdersTable.sortOrder}, ${appsTable.sortOrder})`,
asc(appsTable.nameEn),
);
@@ -98,22 +96,31 @@ router.put("/me/app-order", requireAuth, async (req, res): Promise<void> => {
}
const { order } = parsed.data;
const seen = new Set<number>();
for (const id of order) {
if (seen.has(id)) {
res.status(400).json({ error: `Duplicate app id in order: ${id}` });
return;
}
seen.add(id);
}
const visible = await getVisibleAppsForUser(userId);
const visibleIds = new Set(visible.map((a) => a.id));
const filtered = order.filter((id) => visibleIds.has(id));
const seen = new Set<number>();
const dedup = filtered.filter((id) => {
if (seen.has(id)) return false;
seen.add(id);
return true;
});
const invalid = order.filter((id) => !visibleIds.has(id));
if (invalid.length > 0) {
res.status(400).json({
error: `Unknown or unauthorized app id(s): ${invalid.join(", ")}`,
});
return;
}
await db.transaction(async (tx) => {
await tx.delete(userAppOrdersTable).where(eq(userAppOrdersTable.userId, userId));
if (dedup.length > 0) {
if (order.length > 0) {
await tx.insert(userAppOrdersTable).values(
dedup.map((appId, idx) => ({ userId, appId, sortOrder: idx })),
order.map((appId, idx) => ({ userId, appId, sortOrder: idx })),
);
}
});
@@ -1,369 +0,0 @@
nInPage.tsx
/**
* Standalone Sign-In Page (EN/AR with RTL support)
* ------------------------------------------------------------
* Self-contained: NO shadcn/ui, NO i18n provider, NO auth context.
* Just drop this file into any React + Vite + Tailwind project.
*
* Required dependencies:
* npm i react lucide-react
*
* Tailwind v3+ required. The page uses only standard Tailwind utilities
* (no custom CSS variables) so it works with a default `tailwind.config`.
*
* Usage:
* import { SignInPage } from "./SignInPage";
*
* <SignInPage
* brandName="MyApp"
* loginUrl="/api/auth/login" // POST { email, password }, expects 200 on success
* onSuccess={() => navigate("/dashboard")}
* onForgotPassword={() => navigate("/forgot-password")}
* onSignUp={() => navigate("/sign-up")}
* onHomeClick={() => navigate("/")}
* />
*
* Backend contract (default loginUrl behaviour):
* POST <loginUrl>
* Content-Type: application/json
* Body: { "email": string, "password": string }
* Success: HTTP 2xx (any body)
* Failure: HTTP non-2xx, optionally { "error": "message" }
*
* Override the whole submit by passing `onSubmit` (returns Promise<void>;
* throw an Error to show its message in the alert).
*/
import { useState, type FormEvent } from "react";
import {
Cloud,
CheckCircle2,
Eye,
EyeOff,
Loader2,
AlertCircle,
} from "lucide-react";
type Lang = "en" | "ar";
interface SignInPageProps {
brandName?: string;
loginUrl?: string;
onSubmit?: (creds: { email: string; password: string }) => Promise<void>;
onSuccess?: () => void;
onForgotPassword?: () => void;
onSignUp?: () => void;
onHomeClick?: () => void;
initialLanguage?: Lang;
}
const STRINGS = {
en: {
heroTitle: "Welcome back to your unified platform.",
heroSubtitle: "Sign in to manage everything from one place.",
feat1: "All your tools in one dashboard",
feat2: "Real-time updates",
feat3: "Enterprise-grade security",
title: "Welcome back",
subtitle: "Sign in to your account to continue",
email: "Email address",
password: "Password",
forgot: "Forgot password?",
signIn: "Sign in",
signingIn: "Signing in...",
noAccount: "Don't have an account?",
signUp: "Sign up",
rights: "All rights reserved.",
errRequired: "Please enter your email and password.",
errInvalid: "Invalid email or password.",
errNetwork: "Network error. Please try again.",
},
ar: {
heroTitle: "مرحباً بك في منصتك الموحدة.",
heroSubtitle: "سجّل دخولك لإدارة كل شيء من مكان واحد.",
feat1: "كل أدواتك في لوحة تحكم واحدة",
feat2: "تحديثات لحظية",
feat3: "أمان على مستوى المؤسسات",
title: "مرحباً بعودتك",
subtitle: "سجّل دخولك للمتابعة",
email: "البريد الإلكتروني",
password: "كلمة المرور",
forgot: "نسيت كلمة المرور؟",
signIn: "تسجيل الدخول",
signingIn: "جارٍ الدخول...",
noAccount: "ليس لديك حساب؟",
signUp: "إنشاء حساب",
rights: "جميع الحقوق محفوظة.",
errRequired: "الرجاء إدخال البريد وكلمة المرور.",
errInvalid: "بريد أو كلمة مرور غير صحيحة.",
errNetwork: "خطأ في الشبكة. حاول مرة أخرى.",
},
} as const;
export function SignInPage({
brandName = "MyApp",
loginUrl = "/api/auth/login",
onSubmit,
onSuccess,
onForgotPassword,
onSignUp,
onHomeClick,
initialLanguage = "en",
}: SignInPageProps) {
const [language, setLanguage] = useState<Lang>(initialLanguage);
const t = STRINGS[language];
const dir = language === "ar" ? "rtl" : "ltr";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function defaultSubmit(creds: { email: string; password: string }) {
const res = await fetch(loginUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(creds),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error ?? t.errInvalid);
}
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!email.trim() || !password) {
setError(t.errRequired);
return;
}
setLoading(true);
setError(null);
try {
const submit = onSubmit ?? defaultSubmit;
await submit({ email: email.trim(), password });
onSuccess?.();
} catch (err) {
if (err instanceof Error && err.message) setError(err.message);
else setError(t.errNetwork);
} finally {
setLoading(false);
}
}
return (
<div
className="min-h-screen flex w-full bg-white text-slate-900"
dir={dir}
>
{/* Left brand panel — hidden on small screens */}
<div className="hidden lg:flex w-1/2 bg-slate-900 text-white flex-col justify-between p-12 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(59,130,246,0.15),transparent_50%)] pointer-events-none" />
<div>
<button
type="button"
onClick={onHomeClick}
className="inline-flex items-center gap-3 hover:opacity-90 transition-opacity mb-20 relative z-10"
>
<div className="bg-gradient-to-br from-blue-500 to-blue-700 text-white p-2 rounded-lg shadow-md">
<Cloud className="h-6 w-6" />
</div>
<span className="font-bold text-2xl tracking-tight text-white">
{brandName}
</span>
</button>
<div className="max-w-md relative z-10 space-y-8">
<h1 className="text-4xl font-black text-white leading-tight">
{t.heroTitle}
</h1>
<p className="text-slate-300 text-lg leading-relaxed">
{t.heroSubtitle}
</p>
<div className="space-y-4 pt-4">
{[t.feat1, t.feat2, t.feat3].map((feat) => (
<div
key={feat}
className="flex items-center gap-3 text-slate-200 font-medium"
>
<CheckCircle2 className="h-5 w-5 text-blue-400 shrink-0" />
<span>{feat}</span>
</div>
))}
</div>
</div>
</div>
<div className="relative z-10 text-sm text-slate-400 font-medium">
&copy; {new Date().getFullYear()} {brandName}. {t.rights}
</div>
</div>
{/* Right form panel */}
<div className="flex-1 flex flex-col items-center justify-center p-6 sm:p-8 relative bg-slate-50">
{/* Language toggle */}
<div
className={`absolute top-4 ${
dir === "rtl" ? "left-4 md:left-8" : "right-4 md:right-8"
}`}
>
<button
type="button"
onClick={() => setLanguage(language === "en" ? "ar" : "en")}
className="text-sm font-medium hover:bg-slate-200 px-3 py-1.5 rounded-md transition-colors"
>
{language === "en" ? "عربي" : "EN"}
</button>
</div>
{/* Mobile brand */}
<button
type="button"
onClick={onHomeClick}
className="lg:hidden mb-8 flex items-center gap-2 hover:opacity-90 transition-opacity"
>
<div className="bg-gradient-to-br from-blue-500 to-blue-700 text-white p-2 rounded-lg shadow-sm">
<Cloud className="h-6 w-6" />
</div>
<span className="font-bold text-2xl tracking-tight">{brandName}</span>
</button>
<div className="w-full max-w-[400px]">
<div
className={`mb-8 ${
dir === "rtl" ? "text-center lg:text-right" : "text-center lg:text-left"
}`}
>
<h2 className="text-2xl font-bold tracking-tight">{t.title}</h2>
<p className="text-slate-500 mt-1 text-sm">{t.subtitle}</p>
</div>
<div className="bg-white border border-slate-200 rounded-2xl shadow-xl p-6 sm:p-8">
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
{/* Email */}
<div className="space-y-1.5">
<label
htmlFor="email"
className="block font-semibold text-sm text-slate-800"
>
{t.email}
</label>
<input
id="email"
type="email"
autoComplete="email"
placeholder="you@company.com"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setError(null);
}}
disabled={loading}
className="block w-full h-10 rounded-md border border-slate-300 bg-white px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-60"
/>
</div>
{/* Password */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label
htmlFor="password"
className="font-semibold text-sm text-slate-800"
>
{t.password}
</label>
<button
type="button"
onClick={onForgotPassword}
className="text-xs text-blue-600 hover:text-blue-500 transition-colors font-medium"
>
{t.forgot}
</button>
</div>
<div className="relative">
<input
id="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
placeholder="••••••••"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError(null);
}}
disabled={loading}
className={`block w-full h-10 rounded-md border border-slate-300 bg-white px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-60 ${
dir === "rtl" ? "pl-10" : "pr-10"
}`}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className={`absolute top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors ${
dir === "rtl" ? "left-3" : "right-3"
}`}
tabIndex={-1}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
{/* Error alert */}
{error && (
<div
role="alert"
className="flex items-start gap-2 rounded-md border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-800"
>
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
{/* Submit */}
<button
type="submit"
disabled={loading}
className="w-full h-10 rounded-md bg-blue-600 text-white font-semibold text-sm shadow-sm hover:bg-blue-700 active:bg-blue-800 disabled:opacity-60 disabled:cursor-not-allowed transition-colors inline-flex items-center justify-center"
>
{loading ? (
<>
<Loader2
className={`h-4 w-4 animate-spin ${
dir === "rtl" ? "ml-2" : "mr-2"
}`}
/>
{t.signingIn}
</>
) : (
t.signIn
)}
</button>
</form>
<div className="mt-6 pt-5 border-t border-slate-200 text-center">
<p className="text-sm text-slate-500">
{t.noAccount}{" "}
<button
type="button"
onClick={onSignUp}
className="text-blue-600 font-semibold hover:text-blue-500 transition-colors"
>
{t.signUp}
</button>
</p>
</div>
</div>
</div>
</div>
</div>
);
}