falak-app-duo 1.1.2 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/falak-app-duo +118 -54
- package/package.json +2 -5
- package/templates/default/src/App.vue +86 -24
- package/templates/default/src/assets/main.css +142 -3
- package/templates/default/src/components/AppBackdrop.vue +43 -0
- package/templates/default/src/components/AuthLayout.vue +115 -0
- package/templates/default/src/components/GlassCard.vue +36 -0
- package/templates/default/src/components/SocialIcon.vue +46 -0
- package/templates/default/src/components/ToggleSwitch.vue +31 -0
- package/templates/default/src/components/settings/AIAgentDialog.vue +213 -0
- package/templates/default/src/components/settings/NotificationsDialog.vue +122 -0
- package/templates/default/src/components/settings/SecurityDialog.vue +231 -0
- package/templates/default/src/components/settings/SettingsDialog.vue +115 -0
- package/templates/default/src/components/settings/TelegramDialog.vue +156 -0
- package/templates/default/src/components/settings/TwoFactorDialog.vue +192 -0
- package/templates/default/src/composables/useRipple.js +25 -0
- package/templates/default/src/data/locations.js +59 -0
- package/templates/default/src/i18n/index.js +19 -3
- package/templates/default/src/i18n/locales/ar.json +328 -5
- package/templates/default/src/i18n/locales/de.json +341 -0
- package/templates/default/src/i18n/locales/en.json +328 -5
- package/templates/default/src/i18n/locales/es.json +341 -0
- package/templates/default/src/i18n/locales/fr.json +341 -0
- package/templates/default/src/i18n/locales/it.json +341 -0
- package/templates/default/src/i18n/locales/ru.json +341 -0
- package/templates/default/src/router/index.js +25 -1
- package/templates/default/src/stores/auth.js +79 -3
- package/templates/default/src/views/ForgotPassword.vue +111 -0
- package/templates/default/src/views/Home.vue +182 -6
- package/templates/default/src/views/Login.vue +206 -24
- package/templates/default/src/views/Profile.vue +323 -0
- package/templates/default/src/views/Register.vue +235 -0
- package/templates/default/src/views/SettingsView.vue +200 -0
- package/templates/default/tailwind.config.js +75 -1
package/bin/falak-app-duo
CHANGED
|
@@ -13,13 +13,76 @@ import gradient from "gradient-string";
|
|
|
13
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
const TEMPLATE_DIR = path.join(__dirname, "..", "templates", "default");
|
|
15
15
|
|
|
16
|
+
const LANGUAGE_CHOICES = [
|
|
17
|
+
{ title: "العربية (Arabic) \u2014 the RTL default", value: "ar", selected: true },
|
|
18
|
+
{ title: "Español (Spanish)", value: "es" },
|
|
19
|
+
{ title: "Deutsch (German)", value: "de" },
|
|
20
|
+
{ title: "Français (French)", value: "fr" },
|
|
21
|
+
{ title: "Русский (Russian)", value: "ru" },
|
|
22
|
+
{ title: "Italiano (Italian)", value: "it" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const LOCALE_LABELS = {
|
|
26
|
+
en: "English",
|
|
27
|
+
ar: "العربية",
|
|
28
|
+
es: "Español",
|
|
29
|
+
de: "Deutsch",
|
|
30
|
+
fr: "Français",
|
|
31
|
+
ru: "Русский",
|
|
32
|
+
it: "Italiano",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const EXTRA_LOCALE_FILES = ["ar.json", "de.json", "es.json", "fr.json", "it.json", "ru.json"];
|
|
36
|
+
|
|
37
|
+
function buildI18nModule(locales, defaultLocale) {
|
|
38
|
+
const ordered = ["ar", "en", "es", "de", "fr", "ru", "it"].filter((c) => locales.includes(c));
|
|
39
|
+
const lines = [];
|
|
40
|
+
lines.push('import { createI18n } from "vue-i18n";');
|
|
41
|
+
for (const code of ordered) {
|
|
42
|
+
lines.push(`import ${code} from "./locales/${code}.json";`);
|
|
43
|
+
}
|
|
44
|
+
lines.push("");
|
|
45
|
+
lines.push("export const SUPPORTED_LOCALES = [");
|
|
46
|
+
for (const code of ordered) {
|
|
47
|
+
lines.push(` { code: "${code}", label: "${LOCALE_LABELS[code]}" },`);
|
|
48
|
+
}
|
|
49
|
+
lines.push("];");
|
|
50
|
+
lines.push("");
|
|
51
|
+
lines.push('const STORAGE_KEY = "falak_locale";');
|
|
52
|
+
lines.push("const stored = localStorage.getItem(STORAGE_KEY);");
|
|
53
|
+
lines.push(
|
|
54
|
+
`const savedLocale = SUPPORTED_LOCALES.some((l) => l.code === stored) ? stored : "${defaultLocale}";`
|
|
55
|
+
);
|
|
56
|
+
lines.push("");
|
|
57
|
+
lines.push("const i18n = createI18n({");
|
|
58
|
+
lines.push(" legacy: false,");
|
|
59
|
+
lines.push(" locale: savedLocale,");
|
|
60
|
+
lines.push(' fallbackLocale: "en",');
|
|
61
|
+
lines.push(` messages: { ${ordered.join(", ")} },`);
|
|
62
|
+
lines.push("});");
|
|
63
|
+
lines.push("");
|
|
64
|
+
lines.push("export function setLocale(locale) {");
|
|
65
|
+
lines.push(" i18n.global.locale.value = locale;");
|
|
66
|
+
lines.push(" localStorage.setItem(STORAGE_KEY, locale);");
|
|
67
|
+
lines.push(" document.documentElement.lang = locale;");
|
|
68
|
+
lines.push(' document.documentElement.dir = locale === "ar" ? "rtl" : "ltr";');
|
|
69
|
+
lines.push("}");
|
|
70
|
+
lines.push("");
|
|
71
|
+
lines.push("// Apply on load");
|
|
72
|
+
lines.push("setLocale(savedLocale);");
|
|
73
|
+
lines.push("");
|
|
74
|
+
lines.push("export default i18n;");
|
|
75
|
+
lines.push("");
|
|
76
|
+
return lines.join("\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
16
79
|
const program = new Command();
|
|
17
80
|
program
|
|
18
81
|
.name("falak-app")
|
|
19
82
|
.description("Scaffold a Vue 3 app with Ably, Brevo, AES-256 and a Laravel API client baked in.")
|
|
20
83
|
.argument("[project-name]", "Directory to create the project in")
|
|
21
84
|
.option("-y, --yes", "Skip prompts and use defaults (all services enabled)")
|
|
22
|
-
.option("--no-rtl", "
|
|
85
|
+
.option("--no-rtl", "Exclude Arabic/RTL (English or your chosen LTR languages only)")
|
|
23
86
|
.parse(process.argv);
|
|
24
87
|
|
|
25
88
|
const [argProjectName] = program.args;
|
|
@@ -48,7 +111,7 @@ async function printBanner() {
|
|
|
48
111
|
const art = (await figlet("Falak Duo", { font: "ANSI Shadow" }).catch(() => null)) || "Falak Duo";
|
|
49
112
|
console.log("\n" + gradient("#a78bfa", "#22d3ee", "#2dd4bf")(art));
|
|
50
113
|
console.log(
|
|
51
|
-
chalk.gray(" Vue 3 boilerplate \u2014 Ably \u00b7 Brevo \u00b7 AES-256 \u00b7
|
|
114
|
+
chalk.gray(" Vue 3 boilerplate \u2014 Ably \u00b7 Brevo \u00b7 AES-256 \u00b7 Multilingual / RTL")
|
|
52
115
|
);
|
|
53
116
|
console.log("\n");
|
|
54
117
|
}
|
|
@@ -60,8 +123,8 @@ async function main() {
|
|
|
60
123
|
? {
|
|
61
124
|
projectName: argProjectName || "falak-app-duo",
|
|
62
125
|
services: ["ably", "brevo"],
|
|
126
|
+
languages: ["ar"],
|
|
63
127
|
apiBaseUrl: "http://localhost:8000/api",
|
|
64
|
-
rtl: opts.rtl,
|
|
65
128
|
}
|
|
66
129
|
: await prompts(
|
|
67
130
|
[
|
|
@@ -81,27 +144,34 @@ async function main() {
|
|
|
81
144
|
],
|
|
82
145
|
hint: "Space to toggle, Enter to confirm",
|
|
83
146
|
},
|
|
147
|
+
{
|
|
148
|
+
type: "multiselect",
|
|
149
|
+
name: "languages",
|
|
150
|
+
message: "Which languages should the app ship with? (English is always included)",
|
|
151
|
+
choices: LANGUAGE_CHOICES.map((c) =>
|
|
152
|
+
c.value === "ar" && !opts.rtl ? { ...c, selected: false } : c
|
|
153
|
+
),
|
|
154
|
+
hint: "Space to toggle, Enter to confirm",
|
|
155
|
+
},
|
|
84
156
|
{
|
|
85
157
|
type: "text",
|
|
86
158
|
name: "apiBaseUrl",
|
|
87
159
|
message: "Laravel API base URL:",
|
|
88
160
|
initial: "http://localhost:8000/api",
|
|
89
161
|
},
|
|
90
|
-
...(opts.rtl
|
|
91
|
-
? [
|
|
92
|
-
{
|
|
93
|
-
type: "confirm",
|
|
94
|
-
name: "rtl",
|
|
95
|
-
message: "Enable Arabic / RTL by default?",
|
|
96
|
-
initial: true,
|
|
97
|
-
},
|
|
98
|
-
]
|
|
99
|
-
: []),
|
|
100
162
|
],
|
|
101
163
|
{ onCancel: () => process.exit(1) }
|
|
102
164
|
);
|
|
103
165
|
|
|
104
|
-
|
|
166
|
+
// The set of locales the app ships: English is always bundled, plus the
|
|
167
|
+
// languages the user picked. `--no-rtl` forces Arabic out (LTR only).
|
|
168
|
+
let locales = ["en", ...(answers.languages || [])];
|
|
169
|
+
locales = [...new Set(locales)];
|
|
170
|
+
if (!opts.rtl) {
|
|
171
|
+
locales = locales.filter((l) => l !== "ar");
|
|
172
|
+
}
|
|
173
|
+
const hasRtl = locales.includes("ar");
|
|
174
|
+
const defaultLocale = hasRtl ? "ar" : "en";
|
|
105
175
|
|
|
106
176
|
const projectName = argProjectName || answers.projectName;
|
|
107
177
|
const services = answers.services || [];
|
|
@@ -138,49 +208,39 @@ async function main() {
|
|
|
138
208
|
}
|
|
139
209
|
}
|
|
140
210
|
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
' locale: "en",',
|
|
159
|
-
' fallbackLocale: "en",',
|
|
160
|
-
" messages: { en },",
|
|
161
|
-
"});",
|
|
162
|
-
"",
|
|
163
|
-
].join("\n")
|
|
164
|
-
);
|
|
165
|
-
await fs.remove(path.join(targetDir, "src", "i18n", "locales", "ar.json"));
|
|
211
|
+
// Wire i18n to exactly the selected languages: rewrite index.html lang/dir,
|
|
212
|
+
// regenerate src/i18n/index.js, and drop unselected locale bundles. The
|
|
213
|
+
// template ships Arabic+RTL by default; --no-rtl (or deselecting Arabic)
|
|
214
|
+
// yields an LTR-only app.
|
|
215
|
+
const htmlPath = path.join(targetDir, "index.html");
|
|
216
|
+
const html = await fs.readFile(htmlPath, "utf-8");
|
|
217
|
+
const htmlTag = hasRtl ? '<html lang="ar" dir="rtl">' : '<html lang="en" dir="ltr">';
|
|
218
|
+
await fs.writeFile(htmlPath, html.replace('<html lang="ar" dir="rtl">', htmlTag));
|
|
219
|
+
|
|
220
|
+
const i18nPath = path.join(targetDir, "src", "i18n", "index.js");
|
|
221
|
+
await fs.writeFile(i18nPath, buildI18nModule(locales, defaultLocale));
|
|
222
|
+
|
|
223
|
+
for (const file of EXTRA_LOCALE_FILES) {
|
|
224
|
+
if (!locales.includes(file.replace(".json", ""))) {
|
|
225
|
+
await fs.remove(path.join(targetDir, "src", "i18n", "locales", file));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
166
228
|
|
|
229
|
+
// English-only app: strip the language switcher (and its setup) from App.vue.
|
|
230
|
+
if (locales.length === 1) {
|
|
167
231
|
const appPath = path.join(targetDir, "src", "App.vue");
|
|
168
232
|
let app = await fs.readFile(appPath, "utf-8");
|
|
169
|
-
app = app.replace('import { setLocale } from "@/i18n";\n', "");
|
|
233
|
+
app = app.replace('import { setLocale, SUPPORTED_LOCALES } from "@/i18n";\n', "");
|
|
170
234
|
app = app.replace("const { t, locale } = useI18n();", "const { t } = useI18n();");
|
|
171
|
-
app = app.replace(
|
|
172
|
-
|
|
173
|
-
""
|
|
174
|
-
);
|
|
175
|
-
app = app.replace(
|
|
176
|
-
/\n <button @click="toggleLocale" class="text-sm px-2 py-1 border rounded">\n \{\{ locale === "ar" \? "EN" : "AR" \}\}\n <\/button>/,
|
|
177
|
-
""
|
|
178
|
-
);
|
|
235
|
+
app = app.replace("const locales = SUPPORTED_LOCALES;\n", "");
|
|
236
|
+
app = app.replace(/\n\s*<select\s*v-if="locales\.length > 1"[\s\S]*?<\/select>/, "");
|
|
179
237
|
await fs.writeFile(appPath, app);
|
|
180
|
-
|
|
181
|
-
console.log(chalk.gray(" RTL disabled \u2014 English/LTR only, no Arabic or AR/EN toggle."));
|
|
182
238
|
}
|
|
183
239
|
|
|
240
|
+
console.log(
|
|
241
|
+
chalk.gray(` Languages wired: ${locales.map((l) => LOCALE_LABELS[l]).join(", ")}${hasRtl ? " (RTL default)" : ""}`)
|
|
242
|
+
);
|
|
243
|
+
|
|
184
244
|
// Generate a real .env from .env.example with a fresh encryption key
|
|
185
245
|
const envExamplePath = path.join(targetDir, ".env.example");
|
|
186
246
|
let envContent = await fs.readFile(envExamplePath, "utf-8");
|
|
@@ -235,7 +295,11 @@ async function main() {
|
|
|
235
295
|
await runInProject(targetDir, "npm", ["run", "dev"]).catch(() => {});
|
|
236
296
|
}
|
|
237
297
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
298
|
+
if (process.argv[1] && path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1])) {
|
|
299
|
+
main().catch((err) => {
|
|
300
|
+
console.error(chalk.red(err));
|
|
301
|
+
process.exit(1);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export { buildI18nModule };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "falak-app-duo",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "Vue 3 boilerplate scaffolder — Ably (realtime), Brevo, AES-256, Arabic/RTL, and a Laravel API client (API key + user token auth). No Firebase, no Groq/Paymob on the frontend (Groq is backend-only; Paymob dropped).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -32,8 +32,5 @@
|
|
|
32
32
|
"ably",
|
|
33
33
|
"realtime"
|
|
34
34
|
],
|
|
35
|
-
"license": "MIT"
|
|
36
|
-
"publishConfig": {
|
|
37
|
-
"access": "public"
|
|
38
|
-
}
|
|
35
|
+
"license": "MIT"
|
|
39
36
|
}
|
|
@@ -1,36 +1,98 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { computed } from "vue";
|
|
2
3
|
import { useI18n } from "vue-i18n";
|
|
3
|
-
import {
|
|
4
|
+
import { useRoute, useRouter, RouterLink, RouterView } from "vue-router";
|
|
5
|
+
import { setLocale, SUPPORTED_LOCALES } from "@/i18n";
|
|
4
6
|
import { useAuthStore } from "@/stores/auth";
|
|
5
|
-
import
|
|
7
|
+
import AppBackdrop from "@/components/AppBackdrop.vue";
|
|
6
8
|
|
|
7
9
|
const { t, locale } = useI18n();
|
|
10
|
+
const locales = SUPPORTED_LOCALES;
|
|
8
11
|
const auth = useAuthStore();
|
|
12
|
+
const route = useRoute();
|
|
13
|
+
const router = useRouter();
|
|
9
14
|
|
|
10
|
-
|
|
11
|
-
|
|
15
|
+
// Auth pages hide the app chrome and go full-screen.
|
|
16
|
+
const authPageNames = ["login", "register", "forgot-password"];
|
|
17
|
+
const isAuthPage = computed(() => authPageNames.includes(route.name));
|
|
18
|
+
|
|
19
|
+
const links = computed(() => [
|
|
20
|
+
{ to: "/profile", label: t("nav.profile") },
|
|
21
|
+
{ to: "/settings", label: t("nav.settings") },
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
async function logout() {
|
|
25
|
+
await auth.logout();
|
|
26
|
+
router.push({ name: "home" });
|
|
12
27
|
}
|
|
13
28
|
</script>
|
|
14
29
|
|
|
15
30
|
<template>
|
|
16
|
-
<div class="min-h-screen
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
<div class="relative min-h-screen">
|
|
32
|
+
<AppBackdrop />
|
|
33
|
+
|
|
34
|
+
<div class="relative z-10 flex min-h-screen flex-col">
|
|
35
|
+
<header
|
|
36
|
+
v-if="!isAuthPage"
|
|
37
|
+
class="sticky top-0 z-30 border-b border-white/10 bg-[#0b0f19]/70 backdrop-blur-xl"
|
|
38
|
+
>
|
|
39
|
+
<nav class="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-x-6 gap-y-3 px-4 py-3 sm:px-6">
|
|
40
|
+
<RouterLink to="/" class="group flex items-center gap-2.5">
|
|
41
|
+
<span
|
|
42
|
+
class="grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-base font-black text-emerald-950 shadow-lg shadow-emerald-500/20 transition group-hover:shadow-emerald-400/40"
|
|
43
|
+
>
|
|
44
|
+
F
|
|
45
|
+
</span>
|
|
46
|
+
<span class="text-lg font-bold text-white">{{ t("app.name") }}</span>
|
|
47
|
+
</RouterLink>
|
|
48
|
+
|
|
49
|
+
<div class="flex flex-1 flex-wrap items-center justify-end gap-1.5 text-sm">
|
|
50
|
+
<RouterLink
|
|
51
|
+
to="/"
|
|
52
|
+
class="rounded-full px-3 py-1.5 font-medium transition"
|
|
53
|
+
:class="route.name === 'home' ? 'bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-300/30' : 'text-gray-300 hover:bg-white/5 hover:text-white'"
|
|
54
|
+
>
|
|
55
|
+
{{ t("nav.home") }}
|
|
56
|
+
</RouterLink>
|
|
57
|
+
<template v-if="auth.userToken">
|
|
58
|
+
<RouterLink
|
|
59
|
+
v-for="link in links"
|
|
60
|
+
:key="link.to"
|
|
61
|
+
:to="link.to"
|
|
62
|
+
class="rounded-full px-3 py-1.5 font-medium transition"
|
|
63
|
+
:class="route.path === link.to ? 'bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-300/30' : 'text-gray-300 hover:bg-white/5 hover:text-white'"
|
|
64
|
+
>
|
|
65
|
+
{{ link.label }}
|
|
66
|
+
</RouterLink>
|
|
67
|
+
</template>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div class="flex items-center gap-2.5">
|
|
71
|
+
<RouterLink
|
|
72
|
+
v-if="!auth.userToken"
|
|
73
|
+
to="/login"
|
|
74
|
+
class="btn-primary !px-4 !py-2"
|
|
75
|
+
>
|
|
76
|
+
{{ t("nav.login") }}
|
|
77
|
+
</RouterLink>
|
|
78
|
+
<button v-else @click="logout" class="btn-ghost !px-4 !py-2 !text-rose-300 hover:!bg-rose-500/10 hover:!text-rose-200">
|
|
79
|
+
{{ t("nav.logout") }}
|
|
80
|
+
</button>
|
|
81
|
+
<select
|
|
82
|
+
v-if="locales.length > 1"
|
|
83
|
+
:value="locale"
|
|
84
|
+
@change="setLocale($event.target.value)"
|
|
85
|
+
class="rounded-full border border-white/15 bg-white/5 px-2.5 py-1.5 text-sm font-medium text-gray-300 outline-none transition hover:bg-white/10 hover:text-white"
|
|
86
|
+
>
|
|
87
|
+
<option v-for="l in locales" :key="l.code" :value="l.code">{{ l.label }}</option>
|
|
88
|
+
</select>
|
|
89
|
+
</div>
|
|
90
|
+
</nav>
|
|
91
|
+
</header>
|
|
92
|
+
|
|
93
|
+
<main class="flex-1">
|
|
94
|
+
<RouterView />
|
|
95
|
+
</main>
|
|
96
|
+
</div>
|
|
35
97
|
</div>
|
|
36
|
-
</template>
|
|
98
|
+
</template>
|
|
@@ -2,7 +2,146 @@
|
|
|
2
2
|
@tailwind components;
|
|
3
3
|
@tailwind utilities;
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/* Registered once for the animated gradient-border effect (Tailwind v3 has no
|
|
6
|
+
@property in config, so it lives here in plain CSS next to the keyframes). */
|
|
7
|
+
@property --tw-border-angle {
|
|
8
|
+
syntax: "<angle>";
|
|
9
|
+
initial-value: 0deg;
|
|
10
|
+
inherits: false;
|
|
8
11
|
}
|
|
12
|
+
|
|
13
|
+
@keyframes border-spin {
|
|
14
|
+
to {
|
|
15
|
+
--tw-border-angle: 360deg;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@keyframes ripple-expand {
|
|
20
|
+
to {
|
|
21
|
+
transform: translate(-50%, -50%) scale(20);
|
|
22
|
+
opacity: 0;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@layer base {
|
|
27
|
+
html,
|
|
28
|
+
body {
|
|
29
|
+
font-family: "Almarai", "IBM Plex Sans Arabic", sans-serif;
|
|
30
|
+
background-color: #0b0f19;
|
|
31
|
+
}
|
|
32
|
+
body {
|
|
33
|
+
color: rgb(243 244 246 / 1);
|
|
34
|
+
}
|
|
35
|
+
::selection {
|
|
36
|
+
background: rgb(16 185 129 / 0.35);
|
|
37
|
+
color: #fff;
|
|
38
|
+
}
|
|
39
|
+
* {
|
|
40
|
+
scrollbar-width: thin;
|
|
41
|
+
scrollbar-color: rgb(255 255 255 / 0.14) transparent;
|
|
42
|
+
}
|
|
43
|
+
*::-webkit-scrollbar {
|
|
44
|
+
width: 8px;
|
|
45
|
+
height: 8px;
|
|
46
|
+
}
|
|
47
|
+
*::-webkit-scrollbar-thumb {
|
|
48
|
+
border-radius: 9999px;
|
|
49
|
+
background: rgb(255 255 255 / 0.14);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@layer components {
|
|
54
|
+
/* Reusable glass surfaces — the app's core visual language */
|
|
55
|
+
.glass-card {
|
|
56
|
+
@apply relative rounded-2xl border border-white/10 bg-white/[0.05] shadow-2xl shadow-black/30 backdrop-blur-xl backdrop-saturate-150;
|
|
57
|
+
}
|
|
58
|
+
.glass-card-hover {
|
|
59
|
+
@apply transition-all duration-300 ease-out hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.08] hover:shadow-emerald-500/10;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Fine hairline top highlight so glass reads as "lit" from above */
|
|
63
|
+
.glass-highlight {
|
|
64
|
+
@apply relative overflow-hidden;
|
|
65
|
+
}
|
|
66
|
+
.glass-highlight::before {
|
|
67
|
+
content: "";
|
|
68
|
+
position: absolute;
|
|
69
|
+
inset: 0;
|
|
70
|
+
border-radius: inherit;
|
|
71
|
+
background: linear-gradient(
|
|
72
|
+
180deg,
|
|
73
|
+
rgb(255 255 255 / 0.08) 0%,
|
|
74
|
+
rgb(255 255 255 / 0) 32%
|
|
75
|
+
);
|
|
76
|
+
pointer-events: none;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.glass-input {
|
|
80
|
+
@apply w-full rounded-xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm text-gray-100 placeholder-gray-500 outline-none ring-1 ring-transparent transition focus:border-emerald-400/50 focus:bg-white/[0.08] focus:ring-2 focus:ring-emerald-400/20;
|
|
81
|
+
}
|
|
82
|
+
.glass-select {
|
|
83
|
+
@apply glass-input appearance-none pe-10;
|
|
84
|
+
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M5 8l5 5 5-5' stroke='%239ca3af' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
85
|
+
background-repeat: no-repeat;
|
|
86
|
+
background-position: right 0.9rem center;
|
|
87
|
+
background-size: 1.1rem;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.btn-primary {
|
|
91
|
+
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-emerald-500 via-teal-500 to-cyan-500 bg-[length:200%_200%] px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-emerald-500/25 ring-1 ring-white/10 transition-all duration-200 hover:brightness-110 hover:shadow-emerald-400/40 hover:ring-white/20 animate-gradient-pan motion-reduce:animate-none disabled:cursor-not-allowed disabled:opacity-50 active:translate-y-px;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.btn-ghost {
|
|
95
|
+
@apply inline-flex items-center justify-center gap-2 rounded-xl border border-white/15 bg-white/5 px-4 py-2.5 text-sm font-semibold text-gray-200 shadow-sm backdrop-blur transition-all duration-200 hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* Bright brand accent button — CTA on auth/settings pages */
|
|
99
|
+
.btn-accent {
|
|
100
|
+
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-amber-400 via-orange-400 to-rose-400 bg-[length:200%_200%] px-4 py-2.5 text-sm font-semibold text-amber-950 shadow-lg shadow-amber-500/25 transition-all duration-200 hover:brightness-110 hover:shadow-amber-400/40 animate-gradient-pan motion-reduce:animate-none disabled:cursor-not-allowed disabled:opacity-50 active:translate-y-px;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* Ripple-ready fill button (JS spawns the span, classes live here) */
|
|
104
|
+
.btn-ripple {
|
|
105
|
+
@apply relative isolate overflow-hidden;
|
|
106
|
+
}
|
|
107
|
+
.ripple {
|
|
108
|
+
@apply pointer-events-none absolute rounded-full bg-white/40;
|
|
109
|
+
transform: translate(-50%, -50%) scale(0);
|
|
110
|
+
animation: ripple-expand 0.6s ease-out forwards;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.field-label {
|
|
114
|
+
@apply mb-1.5 block text-sm font-medium text-gray-300;
|
|
115
|
+
}
|
|
116
|
+
.field-hint {
|
|
117
|
+
@apply mt-1 block text-xs text-gray-500;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* Two-stop gradient headline text (2 stops only — 3+ reads muddy at text size) */
|
|
121
|
+
.text-gradient {
|
|
122
|
+
@apply bg-gradient-to-r from-emerald-300 via-teal-200 to-cyan-300 bg-clip-text text-transparent;
|
|
123
|
+
}
|
|
124
|
+
.text-gradient-warm {
|
|
125
|
+
@apply bg-gradient-to-r from-amber-200 via-orange-300 to-rose-300 bg-clip-text text-transparent;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* Small pill / chip */
|
|
129
|
+
.chip {
|
|
130
|
+
@apply inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-gray-300;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* Section heading with gradient marker bar */
|
|
134
|
+
.section-head {
|
|
135
|
+
@apply flex items-center gap-3;
|
|
136
|
+
}
|
|
137
|
+
.section-head::before {
|
|
138
|
+
content: "";
|
|
139
|
+
@apply h-5 w-1 rounded-full bg-gradient-to-b from-emerald-400 to-cyan-400;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* Fine dotted grid used by the backdrop and hero */
|
|
143
|
+
.bg-grid {
|
|
144
|
+
background-image: radial-gradient(rgb(255 255 255 / 0.06) 1px, transparent 1px);
|
|
145
|
+
background-size: 26px 26px;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
// Full-screen dynamic background: dark base + animated aurora mesh of blurred
|
|
3
|
+
// blobs + a fine dotted grid + film grain. Palette deliberately avoids
|
|
4
|
+
// purple/blue gradients (emerald / teal / cyan / rose / amber).
|
|
5
|
+
defineOptions({ name: "AppBackdrop" });
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<div
|
|
10
|
+
aria-hidden="true"
|
|
11
|
+
class="pointer-events-none fixed inset-0 -z-10 overflow-hidden bg-[#0b0f19]"
|
|
12
|
+
>
|
|
13
|
+
<!-- Aurora mesh base -->
|
|
14
|
+
<div
|
|
15
|
+
class="absolute inset-0 bg-[radial-gradient(circle_at_16%_10%,rgba(16,185,129,0.18),transparent_42%),radial-gradient(circle_at_84%_16%,rgba(251,113,133,0.13),transparent_40%),radial-gradient(circle_at_48%_88%,rgba(251,191,36,0.11),transparent_45%),radial-gradient(circle_at_76%_60%,rgba(45,212,191,0.14),transparent_42%),radial-gradient(circle_at_6%_70%,rgba(34,211,238,0.08),transparent_45%)]"
|
|
16
|
+
></div>
|
|
17
|
+
|
|
18
|
+
<!-- Dotted grid, faint -->
|
|
19
|
+
<div class="absolute inset-0 bg-grid opacity-70 [mask-image:radial-gradient(ellipse_at_center,black_30%,transparent_75%)]"></div>
|
|
20
|
+
|
|
21
|
+
<!-- Morphing blobs -->
|
|
22
|
+
<div
|
|
23
|
+
class="absolute -start-32 -top-32 h-[34rem] w-[34rem] rounded-full bg-emerald-500/25 blur-[130px] animate-mesh-drift-a motion-reduce:animate-none"
|
|
24
|
+
></div>
|
|
25
|
+
<div
|
|
26
|
+
class="absolute -end-24 top-1/3 h-[28rem] w-[28rem] rounded-full bg-rose-500/20 blur-[120px] animate-mesh-drift-b motion-reduce:animate-none"
|
|
27
|
+
></div>
|
|
28
|
+
<div
|
|
29
|
+
class="absolute -bottom-40 start-1/4 h-[36rem] w-[36rem] rounded-full bg-amber-400/15 blur-[140px] animate-mesh-drift-a motion-reduce:animate-none"
|
|
30
|
+
style="animation-delay: -7s"
|
|
31
|
+
></div>
|
|
32
|
+
<div
|
|
33
|
+
class="absolute start-[42%] top-12 h-[22rem] w-[22rem] rounded-full bg-teal-400/15 blur-[110px] animate-blob-morph motion-reduce:animate-none"
|
|
34
|
+
></div>
|
|
35
|
+
<div
|
|
36
|
+
class="absolute start-[10%] top-1/2 hidden h-[18rem] w-[18rem] rounded-full bg-cyan-400/10 blur-[100px] animate-blob-morph motion-reduce:animate-none lg:block"
|
|
37
|
+
style="animation-delay: -4s"
|
|
38
|
+
></div>
|
|
39
|
+
|
|
40
|
+
<!-- Vignette so content keeps contrast -->
|
|
41
|
+
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_55%,rgba(5,8,15,0.55))]"></div>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|