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:
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -110,6 +110,8 @@
|
|||||||
"siteNameEn": "اسم الموقع (إنجليزي)",
|
"siteNameEn": "اسم الموقع (إنجليزي)",
|
||||||
"registrationOpen": "السماح بإنشاء حسابات",
|
"registrationOpen": "السماح بإنشاء حسابات",
|
||||||
"registrationOpenHint": "عند الإيقاف، الأدمن فقط يقدر ينشئ مستخدمين جدد.",
|
"registrationOpenHint": "عند الإيقاف، الأدمن فقط يقدر ينشئ مستخدمين جدد.",
|
||||||
|
"footerTextAr": "نص التذييل (عربي)",
|
||||||
|
"footerTextEn": "نص التذييل (إنجليزي)",
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "الرئيسية",
|
"dashboard": "الرئيسية",
|
||||||
"apps": "التطبيقات",
|
"apps": "التطبيقات",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
|
||||||
© {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">
|
||||||
|
© {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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user