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:
riyadhafraa
2026-04-20 10:49:57 +00:00
parent a73d2566bc
commit 40100125da
7 changed files with 111 additions and 44 deletions
+8
View File
@@ -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"
+56 -5
View File
@@ -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(*)` })
+3 -6
View File
@@ -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": "البريد الإلكتروني",
+3 -6
View File
@@ -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",
+15 -1
View File
@@ -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,
},
})}
+3 -10
View File
@@ -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>
+23 -16
View File
@@ -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>
);