Task #5: Refine home and services screens
- Removed greeting and welcome banner from the home page; trimmed unused ar.json/en.json greeting keys. - Hid the 4-tile stats row for non-admin users (`isAdmin` from user roles) and scoped `totalApps` in /api/stats/home to apps the user can actually access (mirrors the RBAC join in /api/apps). - Renamed nav.services translation: "خدماتي" -> "الخدمات", "My Services" -> "Services". - Removed price/free text from the user-facing services page; cards now show name, description, availability badge, and (when present) image. - Admin service editor now exposes an "Image URL" / "رابط الصورة" text field with a live preview thumbnail; saved imageUrl renders at the top of the corresponding service card (16:10, object-cover, onError hides broken images). Uses existing imageUrl column in services schema; no DB migration required. Image upload via object storage was deferred — see follow-up task. Verified: typecheck passes for teaboy-os and api-server; api-server restarted clean; e2e plan validated by testing subagent; architect code review approved.
This commit is contained in:
@@ -27,9 +27,17 @@ externalPort = 8080
|
||||
localPort = 8081
|
||||
externalPort = 80
|
||||
|
||||
[[ports]]
|
||||
localPort = 8082
|
||||
externalPort = 3003
|
||||
|
||||
[[ports]]
|
||||
localPort = 25785
|
||||
externalPort = 3000
|
||||
|
||||
[[ports]]
|
||||
localPort = 25786
|
||||
externalPort = 3002
|
||||
|
||||
[nix]
|
||||
channel = "stable-25_05"
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { eq, and, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "@workspace/db";
|
||||
import {
|
||||
appsTable,
|
||||
appPermissionsTable,
|
||||
rolesTable,
|
||||
rolePermissionsTable,
|
||||
userRolesTable,
|
||||
servicesTable,
|
||||
notificationsTable,
|
||||
usersTable,
|
||||
@@ -16,10 +20,57 @@ const router: IRouter = Router();
|
||||
router.get("/stats/home", requireAuth, async (req, res): Promise<void> => {
|
||||
const userId = req.session.userId!;
|
||||
|
||||
const [appsCount] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(appsTable)
|
||||
.where(eq(appsTable.isActive, true));
|
||||
// Determine if the user is an admin and which apps they can access.
|
||||
const userRoleRows = await db
|
||||
.select({ roleName: rolesTable.name, roleId: userRolesTable.roleId })
|
||||
.from(userRolesTable)
|
||||
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
|
||||
.where(eq(userRolesTable.userId, userId));
|
||||
|
||||
const isAdmin = userRoleRows.some((r) => r.roleName === "admin");
|
||||
|
||||
let totalApps = 0;
|
||||
if (isAdmin) {
|
||||
const [row] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(appsTable)
|
||||
.where(eq(appsTable.isActive, true));
|
||||
totalApps = Number(row?.count ?? 0);
|
||||
} else {
|
||||
const userRoleIds = userRoleRows.map((r) => r.roleId);
|
||||
const userPermissionIds = userRoleIds.length > 0
|
||||
? (await db
|
||||
.select({ permissionId: rolePermissionsTable.permissionId })
|
||||
.from(rolePermissionsTable)
|
||||
.where(inArray(rolePermissionsTable.roleId, userRoleIds))
|
||||
).map((r) => r.permissionId)
|
||||
: [];
|
||||
|
||||
const restrictedRows = await db
|
||||
.select({ appId: appPermissionsTable.appId })
|
||||
.from(appPermissionsTable);
|
||||
const restrictedAppIds = new Set(restrictedRows.map((r) => r.appId));
|
||||
|
||||
const allowedRestrictedAppIds = userPermissionIds.length > 0
|
||||
? new Set(
|
||||
(await db
|
||||
.select({ appId: appPermissionsTable.appId })
|
||||
.from(appPermissionsTable)
|
||||
.where(inArray(appPermissionsTable.permissionId, userPermissionIds))
|
||||
).map((r) => r.appId),
|
||||
)
|
||||
: new Set<number>();
|
||||
|
||||
const activeApps = await db
|
||||
.select({ id: appsTable.id })
|
||||
.from(appsTable)
|
||||
.where(eq(appsTable.isActive, true));
|
||||
totalApps = activeApps.filter(
|
||||
(a) => !restrictedAppIds.has(a.id) || allowedRestrictedAppIds.has(a.id),
|
||||
).length;
|
||||
}
|
||||
|
||||
const appsCount = { count: totalApps };
|
||||
|
||||
const [servicesCount] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"nav": {
|
||||
"home": "الرئيسية",
|
||||
"services": "خدماتي",
|
||||
"services": "الخدمات",
|
||||
"chat": "المحادثات",
|
||||
"notifications": "الإشعارات",
|
||||
"admin": "لوحة التحكم",
|
||||
@@ -21,11 +21,7 @@
|
||||
"displayNameEn": "الاسم (إنجليزي)"
|
||||
},
|
||||
"home": {
|
||||
"goodMorning": "صباح الخير",
|
||||
"goodAfternoon": "مساء الخير",
|
||||
"goodEvening": "مساء الخير",
|
||||
"myApps": "تطبيقاتي",
|
||||
"welcome": "مرحباً بك في نظام TeaBoy"
|
||||
"myApps": "تطبيقاتي"
|
||||
},
|
||||
"services": {
|
||||
"title": "الخدمات",
|
||||
@@ -83,6 +79,7 @@
|
||||
"serviceDescriptionEn": "الوصف (إنجليزي)",
|
||||
"servicePrice": "السعر",
|
||||
"serviceAvailable": "متاح؟",
|
||||
"serviceImageUrl": "رابط الصورة",
|
||||
"users": "المستخدمين",
|
||||
"username": "اسم المستخدم",
|
||||
"email": "البريد الإلكتروني",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"services": "My Services",
|
||||
"services": "Services",
|
||||
"chat": "Chat",
|
||||
"notifications": "Notifications",
|
||||
"admin": "Admin Dashboard",
|
||||
@@ -21,11 +21,7 @@
|
||||
"displayNameEn": "Name (English)"
|
||||
},
|
||||
"home": {
|
||||
"goodMorning": "Good Morning",
|
||||
"goodAfternoon": "Good Afternoon",
|
||||
"goodEvening": "Good Evening",
|
||||
"myApps": "My Apps",
|
||||
"welcome": "Welcome to TeaBoy OS"
|
||||
"myApps": "My Apps"
|
||||
},
|
||||
"services": {
|
||||
"title": "Services",
|
||||
@@ -83,6 +79,7 @@
|
||||
"serviceDescriptionEn": "Description (English)",
|
||||
"servicePrice": "Price",
|
||||
"serviceAvailable": "Available?",
|
||||
"serviceImageUrl": "Image URL",
|
||||
"users": "Users",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
|
||||
@@ -44,6 +44,7 @@ type ServiceForm = {
|
||||
descriptionAr: string;
|
||||
descriptionEn: string;
|
||||
price: string;
|
||||
imageUrl: string;
|
||||
isAvailable: boolean;
|
||||
};
|
||||
|
||||
@@ -84,7 +85,7 @@ export default function AdminPage() {
|
||||
const [newUserForm, setNewUserForm] = useState<{ username: string; email: string; password: string } | null>(null);
|
||||
|
||||
const emptyAppForm: AppForm = { nameAr: "", nameEn: "", slug: "", iconName: "Grid2X2", route: "/", color: "#6366f1", sortOrder: 0 };
|
||||
const emptyServiceForm: ServiceForm = { nameAr: "", nameEn: "", descriptionAr: "", descriptionEn: "", price: "0.00", isAvailable: true };
|
||||
const emptyServiceForm: ServiceForm = { nameAr: "", nameEn: "", descriptionAr: "", descriptionEn: "", price: "0.00", imageUrl: "", isAvailable: true };
|
||||
|
||||
const handleSaveApp = () => {
|
||||
if (!editingApp) return;
|
||||
@@ -220,6 +221,7 @@ export default function AdminPage() {
|
||||
{ field: "descriptionAr", label: t("admin.serviceDescriptionAr") },
|
||||
{ field: "descriptionEn", label: t("admin.serviceDescriptionEn") },
|
||||
{ field: "price", label: t("admin.servicePrice") },
|
||||
{ field: "imageUrl", label: t("admin.serviceImageUrl") },
|
||||
] as { field: keyof ServiceForm; label: string }[]
|
||||
).map(({ field, label }) => (
|
||||
<div key={field} className="space-y-1">
|
||||
@@ -228,9 +230,20 @@ export default function AdminPage() {
|
||||
value={String(editingService.form[field])}
|
||||
onChange={(e) => setEditingService({ ...editingService, form: { ...editingService.form, [field]: e.target.value } })}
|
||||
className="bg-white/70 border-slate-200"
|
||||
placeholder={field === "imageUrl" ? "https://..." : undefined}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{editingService.form.imageUrl && (
|
||||
<div className="rounded-xl overflow-hidden border border-slate-200 bg-slate-50">
|
||||
<img
|
||||
src={editingService.form.imageUrl}
|
||||
alt=""
|
||||
className="w-full h-32 object-cover"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("admin.serviceAvailable")}</Label>
|
||||
<Switch
|
||||
@@ -395,6 +408,7 @@ export default function AdminPage() {
|
||||
descriptionAr: service.descriptionAr ?? "",
|
||||
descriptionEn: service.descriptionEn ?? "",
|
||||
price: service.price ?? "0.00",
|
||||
imageUrl: service.imageUrl ?? "",
|
||||
isAvailable: service.isAvailable ?? true,
|
||||
},
|
||||
})}
|
||||
|
||||
@@ -77,8 +77,7 @@ export default function HomePage() {
|
||||
? (user?.displayNameAr ?? user?.username)
|
||||
: (user?.displayNameEn ?? user?.username);
|
||||
|
||||
const hour = time.getHours();
|
||||
const greeting = hour < 12 ? t("home.goodMorning") : hour < 17 ? t("home.goodAfternoon") : t("home.goodEvening");
|
||||
const isAdmin = user?.roles?.includes("admin") ?? false;
|
||||
|
||||
const handleLogout = () => {
|
||||
logout.mutate(undefined, {
|
||||
@@ -144,14 +143,8 @@ export default function HomePage() {
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 p-6 pb-28 overflow-y-auto">
|
||||
{/* Greeting */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold text-foreground">{greeting}</h2>
|
||||
<p className="text-muted-foreground mt-1">{t("home.welcome")}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
{stats && (
|
||||
{/* Stats Row — admin only */}
|
||||
{isAdmin && stats && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
|
||||
<div className="glass-panel rounded-2xl p-3 text-center">
|
||||
<div className="text-2xl font-bold text-primary">{stats.totalApps}</div>
|
||||
|
||||
@@ -49,29 +49,36 @@ export default function ServicesPage() {
|
||||
{services.map((service) => {
|
||||
const name = lang === "ar" ? service.nameAr : service.nameEn;
|
||||
const description = lang === "ar" ? service.descriptionAr : service.descriptionEn;
|
||||
const price = service.price ? parseFloat(service.price) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={service.id}
|
||||
className="glass-panel rounded-2xl p-5 flex flex-col gap-3"
|
||||
className="glass-panel rounded-2xl overflow-hidden flex flex-col card-hover"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="text-base font-semibold text-foreground">{name}</h3>
|
||||
<Badge
|
||||
variant={service.isAvailable ? "default" : "secondary"}
|
||||
className={service.isAvailable ? "bg-green-500/20 text-green-400 border-green-500/30" : ""}
|
||||
>
|
||||
{service.isAvailable ? t("services.available") : t("services.unavailable")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
{service.imageUrl && (
|
||||
<div className="w-full aspect-[16/10] bg-slate-100 overflow-hidden">
|
||||
<img
|
||||
src={service.imageUrl}
|
||||
alt={name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-5 flex flex-col gap-3 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-base font-semibold text-foreground">{name}</h3>
|
||||
<Badge
|
||||
variant={service.isAvailable ? "default" : "secondary"}
|
||||
className={service.isAvailable ? "bg-emerald-100 text-emerald-700 border-emerald-200" : ""}
|
||||
>
|
||||
{service.isAvailable ? t("services.available") : t("services.unavailable")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto text-sm text-primary font-medium">
|
||||
{price === 0 ? t("services.free") : `${price} ${t("services.price")}`}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user