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:
@@ -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 })),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
-369
@@ -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">
|
||||
© {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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user