falak-app-duo 1.3.1 → 1.3.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/README.md +55 -9
- package/package.json +1 -1
- package/templates/default/src/App.vue +20 -5
- package/templates/default/src/components/dashboard/EntitiesPanel.vue +288 -0
- package/templates/default/src/components/dashboard/OverviewPanel.vue +186 -0
- package/templates/default/src/components/dashboard/SettingsPanel.vue +174 -0
- package/templates/default/src/components/dashboard/UsersPanel.vue +213 -0
- package/templates/default/src/i18n/locales/ar.json +80 -5
- package/templates/default/src/i18n/locales/de.json +80 -5
- package/templates/default/src/i18n/locales/en.json +80 -5
- package/templates/default/src/i18n/locales/es.json +80 -5
- package/templates/default/src/i18n/locales/fr.json +80 -5
- package/templates/default/src/i18n/locales/it.json +80 -5
- package/templates/default/src/i18n/locales/ru.json +80 -5
- package/templates/default/src/router/index.js +23 -1
- package/templates/default/src/services/demoApi.js +151 -3
- package/templates/default/src/stores/admin.js +34 -0
- package/templates/default/src/utils/access.js +33 -0
- package/templates/default/src/views/DashboardView.vue +74 -0
- package/templates/default/src/views/ForgotPassword.vue +5 -5
- package/templates/default/src/views/Profile.vue +18 -14
- package/templates/default/src/views/Register.vue +95 -84
package/README.md
CHANGED
|
@@ -15,6 +15,8 @@ integrations on the frontend side: **Ably** (realtime) and **Brevo**
|
|
|
15
15
|
| Laravel API auth | `X-API-Key` (app) + `Authorization: Bearer <token>` (user) |
|
|
16
16
|
| AES-256 | Kept — encrypts the cached user token in IndexedDB |
|
|
17
17
|
| Arabic/RTL | Kept — baked in via vue-i18n + postcss-rtlcss (opt-out: `--no-rtl` or the RTL prompt) |
|
|
18
|
+
| Admin dashboard | **New** — role-tiered admin UI at `/dashboard` (users, app entities, overview stats, app settings) |
|
|
19
|
+
| Demo mode | Optional — run the whole app in-browser on bundled demo data (`VITE_DATA_MODE=demo`) against an IndexedDB mimic of the Laravel API, no backend needed |
|
|
18
20
|
|
|
19
21
|
## Install & run
|
|
20
22
|
|
|
@@ -23,8 +25,9 @@ No install needed — run it straight with `npx`:
|
|
|
23
25
|
|
|
24
26
|
```bash
|
|
25
27
|
npx falak-app-duo my-project # interactive prompts
|
|
26
|
-
npx falak-app-duo my-project --yes # all defaults: Ably + Brevo, localhost API, RTL on
|
|
28
|
+
npx falak-app-duo my-project --yes # all defaults: Ably + Brevo, localhost API, RTL on, no roles/demo
|
|
27
29
|
npx falak-app-duo my-project --no-rtl # default locale becomes English/LTR
|
|
30
|
+
npx falak-app-duo my-project --roles master,super_admin,admin,user --demo-accounts --demo-data products,orders
|
|
28
31
|
```
|
|
29
32
|
|
|
30
33
|
Or install the CLI globally once:
|
|
@@ -36,6 +39,38 @@ falak-app-duo my-project
|
|
|
36
39
|
|
|
37
40
|
The org-scoped equivalent is `npx @ma7moudsalama/falak-app-duo my-project`.
|
|
38
41
|
|
|
42
|
+
Feature flags:
|
|
43
|
+
|
|
44
|
+
- `--roles <list>` — enable UserRole support for the given comma-separated
|
|
45
|
+
roles (`master,super_admin,admin,user`). Any of `--roles`/`--demo-accounts`/
|
|
46
|
+
`--demo-data` skips the interactive prompts and uses defaults for the rest.
|
|
47
|
+
- `--demo-accounts` — ship one static demo login per selected role (or a plain
|
|
48
|
+
demo user when roles are off) that works immediately on the scratch app.
|
|
49
|
+
- `--demo-data <list>` — include static demo rows for the given entities
|
|
50
|
+
(e.g. `products,orders`); the demo backend then serves full CRUD for them.
|
|
51
|
+
|
|
52
|
+
Roles gate the admin dashboard by tier — `user:0 < admin:1 < super_admin:2 <
|
|
53
|
+
master:3`:
|
|
54
|
+
|
|
55
|
+
- Overview + entity CRUD tabs: any admin (tier ≥ 1).
|
|
56
|
+
- Users + app-settings tabs (`/dashboard`): master/super_admin (tier ≥ 2).
|
|
57
|
+
- Deleting accounts: master only (tier ≥ 2 can edit roles/status, never their
|
|
58
|
+
own account or a higher-tier one).
|
|
59
|
+
|
|
60
|
+
## Demo mode
|
|
61
|
+
|
|
62
|
+
When demo features are selected (demo accounts and/or demo data), `.env` sets
|
|
63
|
+
`VITE_DATA_MODE=demo`. The app then stops calling your API
|
|
64
|
+
and runs entirely in-browser against `src/services/demoApi.js`, which mimics
|
|
65
|
+
the Laravel backend in an IndexedDB database (`falak_demo_db`): a full sign-up/
|
|
66
|
+
sign-in flow, profile + settings writes, the Home `/overview` stats, generic
|
|
67
|
+
entity CRUD, and the same `/admin/*` routes the dashboard panel calls — all
|
|
68
|
+
behind the same `Authorization: Bearer` + admin-tier checks. Reuses login info
|
|
69
|
+
stored in the DB until the seed fingerprint changes. Drop the same frontend on
|
|
70
|
+
a real backend by setting `VITE_DATA_MODE=live` (the default without demo
|
|
71
|
+
features). Client code talks to the data layer through one import,
|
|
72
|
+
`@/services/dataClient`, so swapping modes is a single env-var flip.
|
|
73
|
+
|
|
39
74
|
## Auth model
|
|
40
75
|
|
|
41
76
|
Every request from `src/services/api.js` carries two headers:
|
|
@@ -84,11 +119,17 @@ nothing to scaffold on the client side for it.
|
|
|
84
119
|
## Backend routes this frontend expects
|
|
85
120
|
|
|
86
121
|
```
|
|
87
|
-
POST /api/auth/login
|
|
122
|
+
POST /api/auth/login -> { token, user }
|
|
123
|
+
POST /api/auth/register -> { token, user } (no role sent — backend assigns default)
|
|
88
124
|
POST /api/auth/logout
|
|
89
|
-
GET /api/auth/me
|
|
125
|
+
GET /api/auth/me -> user
|
|
90
126
|
POST /api/broadcasting/auth (Ably/Echo channel authorization)
|
|
91
127
|
POST /api/brevo/email | /api/brevo/sms | /api/brevo/contacts
|
|
128
|
+
GET /api/admin/overview (admin tier ≥ 1)
|
|
129
|
+
GET /api/admin/users (tier ≥ 2)
|
|
130
|
+
PUT /api/admin/users/:id (tier ≥ 2; role/status edits, no self/higher-tier)
|
|
131
|
+
DELETE /api/admin/users/:id (tier ≥ 3, master only)
|
|
132
|
+
GET /api/admin/settings | PUT /api/admin/settings (tier ≥ 2)
|
|
92
133
|
```
|
|
93
134
|
plus an `X-API-Key` middleware gating all of the above, and Sanctum (or
|
|
94
135
|
similar) validating the bearer token for user-scoped routes.
|
|
@@ -97,12 +138,17 @@ similar) validating the bearer token for user-scoped routes.
|
|
|
97
138
|
|
|
98
139
|
1. Prints a gradient **Falak Duo** banner.
|
|
99
140
|
2. In interactive mode asks which services to wire up (**Ably** and/or
|
|
100
|
-
**Brevo**), the Laravel API base URL,
|
|
101
|
-
by default
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
141
|
+
**Brevo**), the Laravel API base URL, whether to enable **Arabic/RTL**
|
|
142
|
+
by default, and which optional extras to include (**UserRole** support,
|
|
143
|
+
**demo accounts**, **demo data** entities). `--yes` takes all defaults
|
|
144
|
+
(no roles/demo); `--no-rtl` skips the RTL choice and defaults to
|
|
145
|
+
English/LTR; any feature flag skips the prompts.
|
|
146
|
+
3. Copies `templates/default` into `my-project/`, strips the service files and
|
|
147
|
+
demo-data modules you didn't select, slices the app to your chosen
|
|
148
|
+
languages, rewrites the role set, and generates a real `.env` from
|
|
149
|
+
`.env.example` with a fresh random AES-256 key + IV, your chosen API base
|
|
150
|
+
URL, and `VITE_DATA_MODE` (`demo` when demo accounts/data are on, else
|
|
151
|
+
`live`).
|
|
106
152
|
4. Offers to run `npm install` and start the dev server for you (`--yes` runs
|
|
107
153
|
`npm install` automatically).
|
|
108
154
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "falak-app-duo",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.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": {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, ref } from "vue";
|
|
2
|
+
import { computed, onMounted, ref } from "vue";
|
|
3
3
|
import { useI18n } from "vue-i18n";
|
|
4
4
|
import { useRoute, useRouter, RouterLink, RouterView } from "vue-router";
|
|
5
5
|
import { setLocale, SUPPORTED_LOCALES } from "@/i18n";
|
|
6
6
|
import { useAuthStore } from "@/stores/auth";
|
|
7
7
|
import { getTheme, toggleTheme } from "@/theme";
|
|
8
|
+
import { isAdmin } from "@/utils/access";
|
|
8
9
|
import AppBackdrop from "@/components/AppBackdrop.vue";
|
|
9
10
|
|
|
10
11
|
const { t, locale } = useI18n();
|
|
@@ -18,10 +19,24 @@ const theme = ref(getTheme());
|
|
|
18
19
|
const authPageNames = ["login", "register", "forgot-password"];
|
|
19
20
|
const isAuthPage = computed(() => authPageNames.includes(route.name));
|
|
20
21
|
|
|
21
|
-
const links = computed(() =>
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
const links = computed(() => {
|
|
23
|
+
const items = [
|
|
24
|
+
{ to: "/profile", label: t("nav.profile") },
|
|
25
|
+
{ to: "/settings", label: t("nav.settings") },
|
|
26
|
+
];
|
|
27
|
+
if (auth.user && isAdmin(auth.user.role)) {
|
|
28
|
+
items.unshift({ to: "/dashboard", label: t("nav.dashboard") });
|
|
29
|
+
}
|
|
30
|
+
return items;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// A logged-in user may land straight on a protected route (refresh): hydrate
|
|
34
|
+
// the account so the nav shows role-gated links (like Dashboard).
|
|
35
|
+
onMounted(() => {
|
|
36
|
+
if (auth.userToken && !auth.user) {
|
|
37
|
+
auth.fetchProfile().catch(() => {});
|
|
38
|
+
}
|
|
39
|
+
});
|
|
25
40
|
|
|
26
41
|
async function logout() {
|
|
27
42
|
await auth.logout();
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { onMounted, ref, watch } from "vue";
|
|
3
|
+
import { useI18n } from "vue-i18n";
|
|
4
|
+
import { client } from "@/services/dataClient";
|
|
5
|
+
import GlassCard from "@/components/GlassCard.vue";
|
|
6
|
+
|
|
7
|
+
const { t } = useI18n();
|
|
8
|
+
|
|
9
|
+
const entities = ref([]);
|
|
10
|
+
const entity = ref("");
|
|
11
|
+
const rows = ref([]);
|
|
12
|
+
const loading = ref(true);
|
|
13
|
+
const error = ref("");
|
|
14
|
+
const message = ref("");
|
|
15
|
+
const editorOpen = ref(false);
|
|
16
|
+
const editing = ref(null); // null = new row, otherwise the record being edited
|
|
17
|
+
const draft = ref({});
|
|
18
|
+
const draftKeys = ref([]);
|
|
19
|
+
|
|
20
|
+
watch(entity, async (name) => {
|
|
21
|
+
if (!name) {
|
|
22
|
+
rows.value = [];
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
await loadRows(name);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
async function loadEntityList() {
|
|
29
|
+
try {
|
|
30
|
+
const { data } = await client.get("/admin/overview");
|
|
31
|
+
entities.value = data.entities || [];
|
|
32
|
+
// Prefer a non-auxiliary entity; fall back to the first one.
|
|
33
|
+
entity.value = entities.value.find((e) => e.name !== "users")?.name || entities.value[0]?.name || "";
|
|
34
|
+
} catch (e) {
|
|
35
|
+
error.value = t("dashboard.entities.error");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function loadRows(name) {
|
|
40
|
+
loading.value = true;
|
|
41
|
+
error.value = "";
|
|
42
|
+
message.value = "";
|
|
43
|
+
try {
|
|
44
|
+
const { data } = await client.get(`/${name}`);
|
|
45
|
+
rows.value = data;
|
|
46
|
+
if (editing.value || draft.value) resetEditor();
|
|
47
|
+
} catch (e) {
|
|
48
|
+
error.value = t("dashboard.entities.error");
|
|
49
|
+
} finally {
|
|
50
|
+
loading.value = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Enumerate editable columns from the first record; exclude `id` (managed by
|
|
55
|
+
// the backend) and long free-text blobs are fine to keep — the editor falls
|
|
56
|
+
// back to a textarea for anything that looks like a description.
|
|
57
|
+
function columnsOf(records) {
|
|
58
|
+
if (!records?.length) return [];
|
|
59
|
+
return Object.keys(records[0]).filter((k) => k !== "id");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function openNew() {
|
|
63
|
+
const cols = columnsOf(rows.value);
|
|
64
|
+
editing.value = null;
|
|
65
|
+
draft.value = Object.fromEntries(cols.map((c) => [c, ""]));
|
|
66
|
+
draftKeys.value = cols;
|
|
67
|
+
editorOpen.value = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function openEdit(record) {
|
|
71
|
+
editing.value = record;
|
|
72
|
+
const cols = columnsOf(rows.value);
|
|
73
|
+
draft.value = { ...record };
|
|
74
|
+
draftKeys.value = cols;
|
|
75
|
+
editorOpen.value = true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resetEditor() {
|
|
79
|
+
editing.value = null;
|
|
80
|
+
draft.value = {};
|
|
81
|
+
draftKeys.value = [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function saveDraft() {
|
|
85
|
+
error.value = "";
|
|
86
|
+
message.value = "";
|
|
87
|
+
try {
|
|
88
|
+
const url = `/${entity.value}${editing.value ? `/${editing.value.id}` : ""}`;
|
|
89
|
+
const res = editing.value ? await client.put(url, draft.value) : await client.post(url, draft.value);
|
|
90
|
+
message.value = editing.value ? t("dashboard.entities.saved") : t("dashboard.entities.created");
|
|
91
|
+
editorOpen.value = false;
|
|
92
|
+
resetEditor();
|
|
93
|
+
await loadRows(entity.value);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
error.value = e?.response?.data?.message || t("dashboard.entities.error");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function removeEntity(row) {
|
|
100
|
+
error.value = "";
|
|
101
|
+
message.value = "";
|
|
102
|
+
try {
|
|
103
|
+
await client.delete(`/${entity.value}/${row.id}`);
|
|
104
|
+
rows.value = rows.value.filter((r) => r.id !== row.id);
|
|
105
|
+
message.value = t("dashboard.entities.deleted");
|
|
106
|
+
} catch (e) {
|
|
107
|
+
error.value = e?.response?.data?.message || t("dashboard.entities.error");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function displayValue(record, key) {
|
|
112
|
+
const v = record[key];
|
|
113
|
+
if (v === null || v === undefined) return "—";
|
|
114
|
+
if (typeof v === "object") return JSON.stringify(v);
|
|
115
|
+
return String(v);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
onMounted(loadEntityList);
|
|
119
|
+
</script>
|
|
120
|
+
|
|
121
|
+
<template>
|
|
122
|
+
<div>
|
|
123
|
+
<p v-if="message" class="mb-4 rounded-xl border border-emerald-400/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-300">
|
|
124
|
+
{{ message }}
|
|
125
|
+
</p>
|
|
126
|
+
<p v-if="error" class="mb-4 rounded-xl border border-rose-400/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-300">
|
|
127
|
+
{{ error }}
|
|
128
|
+
</p>
|
|
129
|
+
|
|
130
|
+
<GlassCard class="p-5">
|
|
131
|
+
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
132
|
+
<h2 class="text-base font-bold text-ink">{{ t("dashboard.entities.title") }}</h2>
|
|
133
|
+
<div class="flex items-center gap-2">
|
|
134
|
+
<select
|
|
135
|
+
v-model="entity"
|
|
136
|
+
class="rounded-xl border border-edge2 bg-glass px-3.5 py-2 text-sm font-medium text-ink outline-none transition focus:ring-1 focus:ring-emerald-300/40"
|
|
137
|
+
>
|
|
138
|
+
<option v-for="e in entities" :key="e.name" :value="e.name">{{ e.name }}</option>
|
|
139
|
+
</select>
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
class="btn-primary !px-4 !py-2"
|
|
143
|
+
:disabled="!entity"
|
|
144
|
+
@click="openNew"
|
|
145
|
+
>
|
|
146
|
+
{{ t("dashboard.entities.add") }}
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<p class="mt-1.5 text-xs text-mute">{{ t("dashboard.entities.subtitle") }}</p>
|
|
152
|
+
|
|
153
|
+
<div class="mt-4 overflow-x-auto">
|
|
154
|
+
<table class="w-full min-w-[560px] border-collapse text-left text-sm rtl:text-right">
|
|
155
|
+
<thead>
|
|
156
|
+
<tr class="border-b border-edge text-xs uppercase tracking-wide text-mute">
|
|
157
|
+
<th class="pb-2 pe-3 font-semibold">#</th>
|
|
158
|
+
<th v-for="key in columnsOf(rows)" :key="key" class="pb-2 pe-3 font-semibold">{{ key }}</th>
|
|
159
|
+
<th class="pb-2 font-semibold">{{ t("dashboard.entities.actions") }}</th>
|
|
160
|
+
</tr>
|
|
161
|
+
</thead>
|
|
162
|
+
<tbody>
|
|
163
|
+
<tr v-if="loading">
|
|
164
|
+
<td :colspan="columnsOf(rows).length + 2" class="py-8 text-center text-mute">{{ t("common.loading") }}</td>
|
|
165
|
+
</tr>
|
|
166
|
+
<tr v-else-if="rows.length === 0">
|
|
167
|
+
<td :colspan="columnsOf(rows).length + 2" class="py-8 text-center text-mute">{{ t("dashboard.entities.empty") }}</td>
|
|
168
|
+
</tr>
|
|
169
|
+
<tr v-for="(row, i) in rows" :key="row.id" class="border-b border-edge/60 last:border-0">
|
|
170
|
+
<td class="py-3 pe-3 font-mono text-xs text-mute">{{ i + 1 }}</td>
|
|
171
|
+
<td v-for="key in columnsOf(rows)" :key="key" class="max-w-[200px] truncate py-3 pe-3 text-mute" :title="displayValue(row, key)">
|
|
172
|
+
{{ displayValue(row, key) }}
|
|
173
|
+
</td>
|
|
174
|
+
<td class="py-3">
|
|
175
|
+
<div class="flex items-center gap-2">
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
class="rounded-lg px-3 py-1.5 text-xs font-semibold text-cyan-300 ring-1 ring-cyan-400/30 transition hover:bg-cyan-500/10"
|
|
179
|
+
@click="openEdit(row)"
|
|
180
|
+
>
|
|
181
|
+
{{ t("dashboard.entities.edit") }}
|
|
182
|
+
</button>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
class="rounded-lg px-3 py-1.5 text-xs font-semibold text-rose-300 ring-1 ring-rose-400/30 transition hover:bg-rose-500/10"
|
|
186
|
+
@click="removeEntity(row)"
|
|
187
|
+
>
|
|
188
|
+
{{ t("dashboard.entities.delete") }}
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
</td>
|
|
192
|
+
</tr>
|
|
193
|
+
</tbody>
|
|
194
|
+
</table>
|
|
195
|
+
</div>
|
|
196
|
+
</GlassCard>
|
|
197
|
+
|
|
198
|
+
<!-- Row editor modal -->
|
|
199
|
+
<Teleport to="body">
|
|
200
|
+
<Transition name="sd" appear>
|
|
201
|
+
<div v-if="editorOpen" class="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-6">
|
|
202
|
+
<div class="sd-backdrop absolute inset-0 bg-[#05070d]/80 backdrop-blur-md" @click="editorOpen = false"></div>
|
|
203
|
+
|
|
204
|
+
<div class="sd-panel relative z-10 flex max-h-[92vh] w-full max-w-xl flex-col overflow-hidden rounded-t-3xl border border-edge bg-page/95 shadow-2xl shadow-black/60 backdrop-blur-2xl sm:rounded-3xl">
|
|
205
|
+
<div class="flex items-start justify-between gap-4 border-b border-edge px-5 py-4 sm:px-6">
|
|
206
|
+
<div>
|
|
207
|
+
<h2 class="text-lg font-bold text-ink">
|
|
208
|
+
{{ editing ? t("dashboard.entities.edit") : t("dashboard.entities.add") }}
|
|
209
|
+
<span class="font-mono text-sm text-teal-300">/{{ entity }}/{{ editing ? editing.id : "new" }}</span>
|
|
210
|
+
</h2>
|
|
211
|
+
</div>
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-edge bg-glass text-mute transition hover:bg-glass3 hover:text-ink"
|
|
215
|
+
:aria-label="t('common.cancel')"
|
|
216
|
+
@click="editorOpen = false"
|
|
217
|
+
>
|
|
218
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
|
|
219
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
220
|
+
</svg>
|
|
221
|
+
</button>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div class="overflow-y-auto overscroll-contain px-5 py-5 sm:px-6 sm:py-6">
|
|
225
|
+
<div v-if="draftKeys.length === 0" class="text-sm text-mute">{{ t("dashboard.entities.empty") }}</div>
|
|
226
|
+
<div v-else class="space-y-4">
|
|
227
|
+
<label v-for="key in draftKeys" :key="key" class="block">
|
|
228
|
+
<span class="mb-1 block font-mono text-xs font-semibold uppercase tracking-wide text-mute">{{ key }}</span>
|
|
229
|
+
<textarea
|
|
230
|
+
v-if="String(draft[key]).length > 60 || typeof draft[key] === 'object'"
|
|
231
|
+
v-model="draft[key]"
|
|
232
|
+
rows="2"
|
|
233
|
+
class="w-full rounded-xl border border-edge2 bg-glass px-3.5 py-2 text-sm text-ink outline-none transition placeholder:text-mute focus:ring-1 focus:ring-emerald-300/40"
|
|
234
|
+
></textarea>
|
|
235
|
+
<input
|
|
236
|
+
v-else
|
|
237
|
+
v-model="draft[key]"
|
|
238
|
+
type="text"
|
|
239
|
+
class="w-full rounded-xl border border-edge2 bg-glass px-3.5 py-2 text-sm text-ink outline-none transition placeholder:text-mute focus:ring-1 focus:ring-emerald-300/40"
|
|
240
|
+
/>
|
|
241
|
+
</label>
|
|
242
|
+
|
|
243
|
+
<div class="flex justify-end gap-2 pt-2">
|
|
244
|
+
<button type="button" class="btn-ghost" @click="editorOpen = false">{{ t("common.cancel") }}</button>
|
|
245
|
+
<button type="button" class="btn-primary" @click="saveDraft">{{ t("common.save") }}</button>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
</Transition>
|
|
252
|
+
</Teleport>
|
|
253
|
+
</div>
|
|
254
|
+
</template>
|
|
255
|
+
|
|
256
|
+
<style scoped>
|
|
257
|
+
.sd-enter-active,
|
|
258
|
+
.sd-leave-active {
|
|
259
|
+
transition: opacity 0.25s ease;
|
|
260
|
+
}
|
|
261
|
+
.sd-enter-from,
|
|
262
|
+
.sd-leave-to {
|
|
263
|
+
opacity: 0;
|
|
264
|
+
}
|
|
265
|
+
.sd-backdrop {
|
|
266
|
+
transition: opacity 0.25s ease;
|
|
267
|
+
}
|
|
268
|
+
.sd-enter-from .sd-backdrop,
|
|
269
|
+
.sd-leave-to .sd-backdrop {
|
|
270
|
+
opacity: 0;
|
|
271
|
+
}
|
|
272
|
+
.sd-panel {
|
|
273
|
+
transition: opacity 0.25s ease, transform 0.25s ease;
|
|
274
|
+
}
|
|
275
|
+
.sd-enter-from .sd-panel,
|
|
276
|
+
.sd-leave-to .sd-panel {
|
|
277
|
+
opacity: 0;
|
|
278
|
+
transform: translateY(24px);
|
|
279
|
+
}
|
|
280
|
+
@media (prefers-reduced-motion: reduce) {
|
|
281
|
+
.sd-enter-active,
|
|
282
|
+
.sd-leave-active,
|
|
283
|
+
.sd-backdrop,
|
|
284
|
+
.sd-panel {
|
|
285
|
+
transition: none;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
</style>
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, onMounted, ref } from "vue";
|
|
3
|
+
import { useI18n } from "vue-i18n";
|
|
4
|
+
import { useAuthStore } from "@/stores/auth";
|
|
5
|
+
import { useAdminStore } from "@/stores/admin";
|
|
6
|
+
import GlassCard from "@/components/GlassCard.vue";
|
|
7
|
+
import { ROLES } from "@/data/roles";
|
|
8
|
+
|
|
9
|
+
const { t } = useI18n();
|
|
10
|
+
const auth = useAuthStore();
|
|
11
|
+
const admin = useAdminStore();
|
|
12
|
+
|
|
13
|
+
const overview = ref(null);
|
|
14
|
+
const loading = ref(true);
|
|
15
|
+
const error = ref("");
|
|
16
|
+
|
|
17
|
+
const stats = computed(() => [
|
|
18
|
+
{ label: t("dashboard.overview.stats.users"), value: overview.value?.stats?.users ?? "—" },
|
|
19
|
+
{ label: t("dashboard.overview.stats.orders"), value: overview.value?.stats?.orders ?? "—" },
|
|
20
|
+
{ label: t("dashboard.overview.stats.products"), value: overview.value?.stats?.products ?? "—" },
|
|
21
|
+
{ label: t("dashboard.overview.stats.revenue"), value: fmtMoney(overview.value?.stats?.revenue) },
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
// The role legend mirrors src/data/roles.js so labels always match the bundle.
|
|
25
|
+
const roleBreakers = computed(() => {
|
|
26
|
+
const counts = overview.value?.roles || {};
|
|
27
|
+
return ROLES.map((role) => ({
|
|
28
|
+
...role,
|
|
29
|
+
count: counts[role.key] || 0,
|
|
30
|
+
})).filter((role) => counts[role.key] != null || role.count > 0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const recentUsers = computed(() => overview.value?.recentUsers || []);
|
|
34
|
+
const entities = computed(() => overview.value?.entities || []);
|
|
35
|
+
const settings = computed(() => overview.value?.settings || {});
|
|
36
|
+
const settingsBadge = computed(() => {
|
|
37
|
+
if (settings.value.maintenance_mode) return t("dashboard.overview.maintenance");
|
|
38
|
+
if (settings.value.registration_open) return t("dashboard.overview.open");
|
|
39
|
+
return t("dashboard.overview.closed");
|
|
40
|
+
});
|
|
41
|
+
const settingsBadgeTint = computed(() =>
|
|
42
|
+
settings.value.maintenance_mode
|
|
43
|
+
? "bg-amber-500/15 text-amber-300 ring-amber-400/20"
|
|
44
|
+
: settings.value.registration_open
|
|
45
|
+
? "bg-emerald-500/15 text-emerald-300 ring-emerald-400/20"
|
|
46
|
+
: "bg-rose-500/15 text-rose-300 ring-rose-400/20"
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
function fmtMoney(value) {
|
|
50
|
+
const n = Number(value);
|
|
51
|
+
if (!Number.isFinite(n)) return "—";
|
|
52
|
+
return new Intl.NumberFormat(undefined, { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(n);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
onMounted(async () => {
|
|
56
|
+
loading.value = true;
|
|
57
|
+
error.value = "";
|
|
58
|
+
try {
|
|
59
|
+
overview.value = await admin.overview();
|
|
60
|
+
} catch (e) {
|
|
61
|
+
error.value = t("dashboard.error");
|
|
62
|
+
} finally {
|
|
63
|
+
loading.value = false;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<template>
|
|
69
|
+
<div>
|
|
70
|
+
<p v-if="error" class="rounded-xl border border-rose-400/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-300">
|
|
71
|
+
{{ error }}
|
|
72
|
+
</p>
|
|
73
|
+
|
|
74
|
+
<div v-if="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
75
|
+
<GlassCard v-for="i in 4" :key="i" class="h-24 p-5">
|
|
76
|
+
<div class="h-3 w-1/2 animate-pulse rounded bg-glass3"></div>
|
|
77
|
+
<div class="mt-4 h-8 w-3/4 animate-pulse rounded bg-glass3"></div>
|
|
78
|
+
</GlassCard>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<template v-else-if="overview">
|
|
82
|
+
<!-- KPI cards -->
|
|
83
|
+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
84
|
+
<GlassCard v-for="s in stats" :key="s.label" hover class="p-5">
|
|
85
|
+
<p class="text-xs font-medium uppercase tracking-wide text-mute">{{ s.label }}</p>
|
|
86
|
+
<p class="mt-2 text-2xl font-extrabold text-gradient">{{ s.value }}</p>
|
|
87
|
+
</GlassCard>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div class="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
91
|
+
<!-- Roles -->
|
|
92
|
+
<GlassCard class="p-5">
|
|
93
|
+
<h2 class="flex items-center gap-2 text-sm font-bold text-ink">
|
|
94
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-teal-300">
|
|
95
|
+
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
96
|
+
<circle cx="9" cy="7" r="4" />
|
|
97
|
+
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
|
|
98
|
+
</svg>
|
|
99
|
+
{{ t("dashboard.overview.roles") }}
|
|
100
|
+
</h2>
|
|
101
|
+
<div class="mt-4 space-y-3">
|
|
102
|
+
<div v-for="role in roleBreakers" :key="role.key" class="flex items-center justify-between gap-3">
|
|
103
|
+
<span class="inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-semibold ring-1" :class="role.badge">
|
|
104
|
+
{{ t(role.label) }}
|
|
105
|
+
</span>
|
|
106
|
+
<span class="text-sm font-bold text-ink">{{ role.count }}</span>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</GlassCard>
|
|
110
|
+
|
|
111
|
+
<!-- Recent users -->
|
|
112
|
+
<GlassCard class="p-5">
|
|
113
|
+
<h2 class="flex items-center gap-2 text-sm font-bold text-ink">
|
|
114
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-sky-300">
|
|
115
|
+
<circle cx="12" cy="8" r="4" />
|
|
116
|
+
<path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1" />
|
|
117
|
+
</svg>
|
|
118
|
+
{{ t("dashboard.overview.recentUsers") }}
|
|
119
|
+
</h2>
|
|
120
|
+
<ul class="mt-4 space-y-2.5">
|
|
121
|
+
<li v-for="u in recentUsers" :key="u.id" class="flex items-center justify-between gap-3">
|
|
122
|
+
<div class="flex min-w-0 items-center gap-3">
|
|
123
|
+
<span
|
|
124
|
+
class="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-gradient-to-br from-emerald-400/20 to-teal-500/20 text-xs font-bold text-emerald-200 ring-1 ring-edge"
|
|
125
|
+
>
|
|
126
|
+
{{ (u.name || u.email || "?").charAt(0).toUpperCase() }}
|
|
127
|
+
</span>
|
|
128
|
+
<div class="min-w-0">
|
|
129
|
+
<p class="truncate text-sm font-medium text-ink">{{ u.name || "—" }}</p>
|
|
130
|
+
<p class="truncate text-xs text-mute">{{ u.email }}</p>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
<span
|
|
134
|
+
v-if="u.role"
|
|
135
|
+
class="shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1"
|
|
136
|
+
:class="ROLES.find((r) => r.key === u.role)?.badge || 'bg-glass text-mute ring-edge2'"
|
|
137
|
+
>
|
|
138
|
+
{{ t(`roles.${u.role}.title`) }}
|
|
139
|
+
</span>
|
|
140
|
+
</li>
|
|
141
|
+
</ul>
|
|
142
|
+
</GlassCard>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div class="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
146
|
+
<!-- Entities -->
|
|
147
|
+
<GlassCard class="p-5">
|
|
148
|
+
<h2 class="flex items-center gap-2 text-sm font-bold text-ink">
|
|
149
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-cyan-300">
|
|
150
|
+
<rect x="3" y="4" width="18" height="16" rx="2" />
|
|
151
|
+
<path d="M3 9h18M8 4v5M16 4v5" />
|
|
152
|
+
</svg>
|
|
153
|
+
{{ t("dashboard.overview.entities") }}
|
|
154
|
+
</h2>
|
|
155
|
+
<ul class="mt-4 space-y-2.5">
|
|
156
|
+
<li v-for="e in entities" :key="e.name" class="flex items-center justify-between gap-3">
|
|
157
|
+
<span class="rounded-lg border border-edge bg-glass px-2.5 py-1 font-mono text-xs font-semibold text-teal-200">
|
|
158
|
+
{{ e.name }}
|
|
159
|
+
</span>
|
|
160
|
+
<span class="text-sm font-bold text-ink">{{ e.count }}</span>
|
|
161
|
+
</li>
|
|
162
|
+
</ul>
|
|
163
|
+
</GlassCard>
|
|
164
|
+
|
|
165
|
+
<!-- App settings summary -->
|
|
166
|
+
<GlassCard class="p-5">
|
|
167
|
+
<h2 class="flex items-center gap-2 text-sm font-bold text-ink">
|
|
168
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-amber-300">
|
|
169
|
+
<circle cx="12" cy="12" r="3" />
|
|
170
|
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
|
|
171
|
+
</svg>
|
|
172
|
+
{{ t("dashboard.overview.settings") }}
|
|
173
|
+
</h2>
|
|
174
|
+
<div class="mt-4 flex flex-wrap items-center gap-3">
|
|
175
|
+
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ring-1" :class="settingsBadgeTint">
|
|
176
|
+
{{ settingsBadge }}
|
|
177
|
+
</span>
|
|
178
|
+
<span v-if="settings.app_name" class="rounded-lg border border-edge bg-glass px-2.5 py-1 font-mono text-xs font-semibold text-mute">
|
|
179
|
+
{{ settings.app_name }}
|
|
180
|
+
</span>
|
|
181
|
+
</div>
|
|
182
|
+
</GlassCard>
|
|
183
|
+
</div>
|
|
184
|
+
</template>
|
|
185
|
+
</div>
|
|
186
|
+
</template>
|