Update login screen and site settings with editable footer and AI art

Add editable footer text fields to site settings, update OpenAPI schema and API client, refactor the login page to display AI-generated artwork and use dynamic footer text, and remove the old logo and welcome heading.
This commit is contained in:
Riyadh
2026-04-20 12:28:25 +00:00
parent b76840d863
commit 75591e9acf
9 changed files with 99 additions and 77 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+2
View File
@@ -110,6 +110,8 @@
"siteNameEn": "اسم الموقع (إنجليزي)", "siteNameEn": "اسم الموقع (إنجليزي)",
"registrationOpen": "السماح بإنشاء حسابات", "registrationOpen": "السماح بإنشاء حسابات",
"registrationOpenHint": "عند الإيقاف، الأدمن فقط يقدر ينشئ مستخدمين جدد.", "registrationOpenHint": "عند الإيقاف، الأدمن فقط يقدر ينشئ مستخدمين جدد.",
"footerTextAr": "نص التذييل (عربي)",
"footerTextEn": "نص التذييل (إنجليزي)",
"nav": { "nav": {
"dashboard": "الرئيسية", "dashboard": "الرئيسية",
"apps": "التطبيقات", "apps": "التطبيقات",
+2
View File
@@ -110,6 +110,8 @@
"siteNameEn": "Site Name (English)", "siteNameEn": "Site Name (English)",
"registrationOpen": "Allow public registration", "registrationOpen": "Allow public registration",
"registrationOpenHint": "When off, only admins can create new users.", "registrationOpenHint": "When off, only admins can create new users.",
"footerTextAr": "Footer Text (Arabic)",
"footerTextEn": "Footer Text (English)",
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"apps": "Apps", "apps": "Apps",
+27 -1
View File
@@ -595,13 +595,21 @@ function SiteSettingsPanel() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } }); const { data } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } });
const update = useUpdateAppSettings(); const update = useUpdateAppSettings();
const [form, setForm] = useState({ siteNameAr: "", siteNameEn: "", registrationOpen: true }); const [form, setForm] = useState({
siteNameAr: "",
siteNameEn: "",
registrationOpen: true,
footerTextAr: "",
footerTextEn: "",
});
useEffect(() => { useEffect(() => {
if (data) setForm({ if (data) setForm({
siteNameAr: data.siteNameAr, siteNameAr: data.siteNameAr,
siteNameEn: data.siteNameEn, siteNameEn: data.siteNameEn,
registrationOpen: data.registrationOpen, registrationOpen: data.registrationOpen,
footerTextAr: data.footerTextAr,
footerTextEn: data.footerTextEn,
}); });
}, [data]); }, [data]);
@@ -638,6 +646,24 @@ function SiteSettingsPanel() {
dir="ltr" dir="ltr"
/> />
</div> </div>
<div className="space-y-1">
<Label>{t("admin.footerTextAr")}</Label>
<Input
value={form.footerTextAr}
onChange={(e) => setForm({ ...form, footerTextAr: e.target.value })}
className="bg-white/70 border-slate-200"
dir="rtl"
/>
</div>
<div className="space-y-1">
<Label>{t("admin.footerTextEn")}</Label>
<Input
value={form.footerTextEn}
onChange={(e) => setForm({ ...form, footerTextEn: e.target.value })}
className="bg-white/70 border-slate-200"
dir="ltr"
/>
</div>
<div className="flex items-center justify-between pt-2"> <div className="flex items-center justify-between pt-2">
<div> <div>
<Label className="font-medium">{t("admin.registrationOpen")}</Label> <Label className="font-medium">{t("admin.registrationOpen")}</Label>
+23 -75
View File
@@ -5,14 +5,8 @@ import { useLogin, getGetMeQueryKey } from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useAppName, useAppSettings } from "@/hooks/use-app-settings"; import { useAppName, useAppSettings } from "@/hooks/use-app-settings";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { import { Eye, EyeOff, Loader2, AlertCircle } from "lucide-react";
Sparkles, import loginArt from "@/assets/login-art.png";
CheckCircle2,
Eye,
EyeOff,
Loader2,
AlertCircle,
} from "lucide-react";
export default function LoginPage() { export default function LoginPage() {
const { t, i18n: i18nInstance } = useTranslation(); const { t, i18n: i18nInstance } = useTranslation();
@@ -56,57 +50,27 @@ export default function LoginPage() {
const toggleLanguage = () => i18n.changeLanguage(lang === "ar" ? "en" : "ar"); const toggleLanguage = () => i18n.changeLanguage(lang === "ar" ? "en" : "ar");
const features = [ const footerText =
t("auth.feat1"), lang === "ar" ? settings?.footerTextAr : settings?.footerTextEn;
t("auth.feat2"),
t("auth.feat3"),
];
return ( return (
<div className="min-h-screen flex w-full text-foreground" dir={dir}> <div className="min-h-screen flex w-full text-foreground" dir={dir}>
{/* Left brand panel — hidden on small screens */} {/* Left art panel — hidden on small screens, no logo, no text */}
<div className="hidden lg:flex w-1/2 os-bg flex-col justify-between p-12 relative overflow-hidden"> <div className="hidden lg:block w-1/2 relative overflow-hidden os-bg">
<div className="absolute inset-0 pointer-events-none" <img
src={loginArt}
alt=""
aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover"
/>
{/* Soft brand-tinted overlay so the art feels integrated with the OS palette */}
<div
className="absolute inset-0 mix-blend-overlay pointer-events-none"
style={{ style={{
background: background:
"radial-gradient(circle at top right, hsl(var(--primary) / 0.18), transparent 55%), radial-gradient(circle at bottom left, hsl(var(--accent) / 0.16), transparent 55%)", "linear-gradient(135deg, hsl(var(--primary) / 0.25), transparent 55%, hsl(var(--accent) / 0.25))",
}} }}
/> />
<div className="relative z-10">
<div className="inline-flex items-center gap-3 mb-20">
<div className="icon-tile w-12 h-12 icon-tile-blue">
<Sparkles size={24} color="#FFFFFF" />
</div>
<span className="font-bold text-2xl tracking-tight text-foreground">
{appName}
</span>
</div>
<div className="max-w-md space-y-8">
<h1 className="text-4xl font-black text-foreground leading-tight">
{t("auth.heroTitle")}
</h1>
<p className="text-muted-foreground text-lg leading-relaxed">
{t("auth.heroSubtitle")}
</p>
<div className="space-y-4 pt-4">
{features.map((feat) => (
<div
key={feat}
className="flex items-center gap-3 text-foreground/90 font-medium"
>
<CheckCircle2 className="h-5 w-5 text-primary shrink-0" />
<span>{feat}</span>
</div>
))}
</div>
</div>
</div>
<div className="relative z-10 text-sm text-muted-foreground font-medium">
&copy; {new Date().getFullYear()} {appName}. {t("auth.rights")}
</div>
</div> </div>
{/* Right form panel */} {/* Right form panel */}
@@ -126,30 +90,7 @@ export default function LoginPage() {
</button> </button>
</div> </div>
{/* Mobile brand */}
<div className="lg:hidden mb-8 flex items-center gap-2">
<div className="icon-tile w-10 h-10 icon-tile-blue">
<Sparkles size={20} color="#FFFFFF" />
</div>
<span className="font-bold text-2xl tracking-tight">{appName}</span>
</div>
<div className="w-full max-w-[400px]"> <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("auth.welcomeBack")}
</h2>
<p className="text-muted-foreground mt-1 text-sm">
{t("auth.subtitle")}
</p>
</div>
<div className="glass-panel rounded-2xl p-6 sm:p-8"> <div className="glass-panel rounded-2xl p-6 sm:p-8">
<form onSubmit={handleSubmit} className="space-y-5" noValidate> <form onSubmit={handleSubmit} className="space-y-5" noValidate>
{/* Username */} {/* Username */}
@@ -259,6 +200,13 @@ export default function LoginPage() {
</form> </form>
</div> </div>
</div> </div>
{/* Admin-editable footer */}
{footerText && (
<div className="absolute bottom-4 inset-x-0 text-center text-xs text-muted-foreground px-4">
&copy; {new Date().getFullYear()} {appName}. {footerText}
</div>
)}
</div> </div>
</div> </div>
); );
@@ -261,6 +261,8 @@ export interface AppSettings {
siteNameAr: string; siteNameAr: string;
siteNameEn: string; siteNameEn: string;
registrationOpen: boolean; registrationOpen: boolean;
footerTextAr: string;
footerTextEn: string;
updatedAt?: string; updatedAt?: string;
} }
@@ -281,6 +283,16 @@ export interface UpdateAppSettingsBody {
*/ */
siteNameEn?: string; siteNameEn?: string;
registrationOpen?: boolean; registrationOpen?: boolean;
/**
* @minLength 1
* @maxLength 300
*/
footerTextAr?: string;
/**
* @minLength 1
* @maxLength 300
*/
footerTextEn?: string;
} }
export interface RequestUploadUrlBody { export interface RequestUploadUrlBody {
+13 -1
View File
@@ -1219,7 +1219,7 @@ components:
AppSettings: AppSettings:
type: object type: object
required: [siteNameAr, siteNameEn, registrationOpen] required: [siteNameAr, siteNameEn, registrationOpen, footerTextAr, footerTextEn]
properties: properties:
siteNameAr: siteNameAr:
type: string type: string
@@ -1227,6 +1227,10 @@ components:
type: string type: string
registrationOpen: registrationOpen:
type: boolean type: boolean
footerTextAr:
type: string
footerTextEn:
type: string
updatedAt: updatedAt:
type: string type: string
format: date-time format: date-time
@@ -1254,6 +1258,14 @@ components:
maxLength: 200 maxLength: 200
registrationOpen: registrationOpen:
type: boolean type: boolean
footerTextAr:
type: string
minLength: 1
maxLength: 300
footerTextEn:
type: string
minLength: 1
maxLength: 300
RequestUploadUrlBody: RequestUploadUrlBody:
type: object type: object
+18
View File
@@ -231,6 +231,8 @@ export const GetAppSettingsResponse = zod.object({
siteNameAr: zod.string(), siteNameAr: zod.string(),
siteNameEn: zod.string(), siteNameEn: zod.string(),
registrationOpen: zod.boolean(), registrationOpen: zod.boolean(),
footerTextAr: zod.string(),
footerTextEn: zod.string(),
updatedAt: zod.coerce.date().optional(), updatedAt: zod.coerce.date().optional(),
}); });
@@ -241,6 +243,10 @@ export const updateAppSettingsBodySiteNameArMax = 200;
export const updateAppSettingsBodySiteNameEnMax = 200; export const updateAppSettingsBodySiteNameEnMax = 200;
export const updateAppSettingsBodyFooterTextArMax = 300;
export const updateAppSettingsBodyFooterTextEnMax = 300;
export const UpdateAppSettingsBody = zod.object({ export const UpdateAppSettingsBody = zod.object({
siteNameAr: zod siteNameAr: zod
.string() .string()
@@ -253,12 +259,24 @@ export const UpdateAppSettingsBody = zod.object({
.max(updateAppSettingsBodySiteNameEnMax) .max(updateAppSettingsBodySiteNameEnMax)
.optional(), .optional(),
registrationOpen: zod.boolean().optional(), registrationOpen: zod.boolean().optional(),
footerTextAr: zod
.string()
.min(1)
.max(updateAppSettingsBodyFooterTextArMax)
.optional(),
footerTextEn: zod
.string()
.min(1)
.max(updateAppSettingsBodyFooterTextEnMax)
.optional(),
}); });
export const UpdateAppSettingsResponse = zod.object({ export const UpdateAppSettingsResponse = zod.object({
siteNameAr: zod.string(), siteNameAr: zod.string(),
siteNameEn: zod.string(), siteNameEn: zod.string(),
registrationOpen: zod.boolean(), registrationOpen: zod.boolean(),
footerTextAr: zod.string(),
footerTextEn: zod.string(),
updatedAt: zod.coerce.date().optional(), updatedAt: zod.coerce.date().optional(),
}); });
+2
View File
@@ -7,6 +7,8 @@ export const appSettingsTable = pgTable("app_settings", {
siteNameAr: varchar("site_name_ar", { length: 200 }).notNull().default("نظام TeaBoy"), siteNameAr: varchar("site_name_ar", { length: 200 }).notNull().default("نظام TeaBoy"),
siteNameEn: varchar("site_name_en", { length: 200 }).notNull().default("TeaBoy OS"), siteNameEn: varchar("site_name_en", { length: 200 }).notNull().default("TeaBoy OS"),
registrationOpen: boolean("registration_open").notNull().default(true), registrationOpen: boolean("registration_open").notNull().default(true),
footerTextAr: varchar("footer_text_ar", { length: 300 }).notNull().default("جميع الحقوق محفوظة."),
footerTextEn: varchar("footer_text_en", { length: 300 }).notNull().default("All rights reserved."),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
}); });