Skip to content

Commit 5456e72

Browse files
authored
Merge pull request #2625 from ankaboot-source/fix/sources-tab-ui-polish
fix: polish sources tab cards and loading state
2 parents 1a305d4 + da987d1 commit 5456e72

File tree

7 files changed

+681
-439
lines changed

7 files changed

+681
-439
lines changed

frontend/src/pages/sources.vue

Lines changed: 85 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,39 @@
66
<h1 class="text-xl font-semibold">{{ t('sources') }}</h1>
77
</div>
88

9+
<div
10+
v-if="
11+
$leadminer.isLoadingMiningSources && !$leadminer.miningSources.length
12+
"
13+
class="grid gap-3"
14+
>
15+
<div
16+
v-for="n in 3"
17+
:key="`source-skeleton-${n}`"
18+
class="border border-surface-200 rounded-md p-4"
19+
>
20+
<div class="flex items-center justify-between gap-3 flex-wrap">
21+
<div class="flex flex-col gap-2">
22+
<Skeleton width="8rem" height="1rem" />
23+
<Skeleton width="14rem" height="0.85rem" />
24+
</div>
25+
<div class="flex items-center gap-2">
26+
<Skeleton width="5.5rem" height="2rem" />
27+
<Skeleton width="2.8rem" height="1.6rem" />
28+
<Skeleton width="5.2rem" height="1.75rem" />
29+
</div>
30+
</div>
31+
32+
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4">
33+
<Skeleton height="4.5rem" />
34+
<Skeleton height="4.5rem" />
35+
<Skeleton height="4.5rem" />
36+
</div>
37+
</div>
38+
</div>
39+
940
<DataView
41+
v-else
1042
:value="$leadminer.miningSources"
1143
data-key="email"
1244
:paginator="true"
@@ -18,98 +50,73 @@
1850
</div>
1951
</template>
2052
<template #list="slotProps">
21-
<div class="grid gap-2">
53+
<div class="grid gap-3">
2254
<div
2355
v-for="source in slotProps.items"
2456
:key="source.email"
2557
class="border border-surface-200 rounded-md p-4"
2658
>
27-
<div class="flex items-center justify-between gap-2">
28-
<div class="flex items-center gap-2">
29-
<div class="font-medium">{{ source.email }}</div>
30-
<Tag
31-
:value="
32-
source.isValid ? t('connected') : t('credential_expired')
33-
"
34-
:severity="source.isValid ? 'success' : 'warn'"
35-
/>
59+
<div class="flex items-start justify-between gap-3 flex-wrap">
60+
<div>
61+
<div class="font-medium">{{ source.type }}</div>
62+
<div class="text-sm text-surface-500">{{ source.email }}</div>
3663
</div>
37-
<div class="flex items-center gap-2">
38-
<Button
39-
v-if="isActiveMiningSource(source)"
40-
size="small"
41-
outlined
42-
severity="warning"
43-
icon="pi pi-stop"
44-
:label="t('stop_mining')"
45-
:loading="isStoppingMining"
46-
@click="confirmStopMining"
47-
/>
64+
65+
<div class="flex items-center justify-end gap-2 flex-wrap">
66+
<div class="flex items-center gap-2 text-sm text-surface-600">
67+
<span>{{ t('continuous_mining') }}</span>
68+
<ToggleSwitch
69+
v-model="source.passive_mining"
70+
@update:model-value="
71+
(val: boolean) =>
72+
togglePassiveMining(source.email, source.type, val)
73+
"
74+
/>
75+
</div>
76+
4877
<Button
4978
size="small"
5079
outlined
5180
severity="danger"
5281
icon="pi pi-trash"
53-
:label="t('delete_source')"
82+
:label="t('remove')"
5483
:loading="
5584
isDeleting && deletingSource?.email === source.email
5685
"
5786
@click="openDeleteDialog(source)"
5887
/>
88+
89+
<Tag
90+
:value="
91+
source.isValid ? t('connected') : t('credential_expired')
92+
"
93+
:severity="source.isValid ? 'success' : 'warn'"
94+
/>
5995
</div>
6096
</div>
6197

62-
<div class="grid grid-cols-2 lg:grid-cols-4 gap-2 mt-4 text-sm">
98+
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4 text-sm">
6399
<div class="p-2 rounded bg-surface-50">
64-
<div class="text-surface-500">{{ t('type') }}</div>
65-
<div class="flex items-center gap-2 font-medium">
100+
<div class="text-surface-500">{{ t('provider') }}</div>
101+
<div class="flex items-center gap-2 font-semibold mt-1">
66102
<i
67103
:class="getIcon(source.type)"
68104
class="text-secondary size-xs"
69105
></i>
70-
<span>{{ source.type }}</span>
71-
</div>
72-
</div>
73-
74-
<div class="p-2 rounded bg-surface-50">
75-
<div class="text-surface-500">{{ t('passive_mining') }}</div>
76-
<div class="flex items-center gap-2">
77-
<span
78-
v-if="source.passive_mining"
79-
class="relative flex h-3 w-3"
80-
>
81-
<span
82-
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
83-
></span>
84-
<span
85-
class="relative inline-flex h-3 w-3 rounded-full bg-green-500"
86-
></span>
87-
</span>
88-
<span
89-
v-else
90-
class="inline-flex h-3 w-3 rounded-full bg-gray-500"
91-
></span>
92-
<span>{{
93-
source.passive_mining ? t('enabled') : t('disabled')
94-
}}</span>
95-
<ToggleSwitch
96-
v-model="source.passive_mining"
97-
@update:model-value="
98-
(val: boolean) =>
99-
togglePassiveMining(source.email, source.type, val)
100-
"
101-
/>
106+
<span class="capitalize">{{ source.type }}</span>
102107
</div>
103108
</div>
104109

105110
<div class="p-2 rounded bg-surface-50">
106111
<div class="text-surface-500">{{ t('total_contacts') }}</div>
107-
<div class="font-medium">{{ source.totalContacts || 0 }}</div>
112+
<div class="font-semibold mt-1">
113+
{{ source.totalContacts || 0 }}
114+
</div>
108115
</div>
109116

110117
<div class="p-2 rounded bg-surface-50">
111-
<div class="text-surface-500">{{ t('last_mining') }}</div>
112-
<div class="font-medium">
118+
<div class="text-surface-500">{{ t('last_extraction') }}</div>
119+
<div class="font-semibold mt-1">
113120
{{
114121
source.lastMiningDate
115122
? formatDate(source.lastMiningDate)
@@ -176,19 +183,19 @@
176183
<Dialog
177184
v-model:visible="deleteDialogVisible"
178185
modal
179-
:header="t('delete_source')"
186+
:header="t('remove_source')"
180187
:style="{ width: '28rem', maxWidth: '95vw' }"
181188
>
182189
<div class="text-sm text-surface-700">
183-
{{ t('delete_source_confirm') }}
190+
{{ t('remove_source_confirm') }}
184191
</div>
185192

186193
<template #footer>
187194
<div class="flex justify-end gap-2 w-full">
188195
<Button outlined :label="t('cancel')" @click="closeDeleteDialog" />
189196
<Button
190197
severity="danger"
191-
:label="t('delete_source')"
198+
:label="t('remove')"
192199
:loading="isDeleting"
193200
@click="confirmDelete"
194201
/>
@@ -211,7 +218,6 @@ const $toast = useToast();
211218
const deleteDialogVisible = ref(false);
212219
const deletingSource = ref<MiningSource | null>(null);
213220
const isDeleting = ref(false);
214-
const isStoppingMining = ref(false);
215221
216222
function getIcon(type: string) {
217223
switch (type) {
@@ -268,7 +274,7 @@ async function confirmDelete() {
268274
} catch (error) {
269275
$toast.add({
270276
severity: 'error',
271-
summary: t('delete_source_failed'),
277+
summary: t('remove_source_failed'),
272278
detail: (error as Error).message,
273279
life: 4500,
274280
});
@@ -277,28 +283,6 @@ async function confirmDelete() {
277283
}
278284
}
279285
280-
async function confirmStopMining() {
281-
isStoppingMining.value = true;
282-
try {
283-
await $leadminer.stopMining(true, null);
284-
$toast.add({
285-
severity: 'success',
286-
summary: t('mining_stopped'),
287-
detail: t('mining_stopped_detail'),
288-
life: 3500,
289-
});
290-
} catch (error) {
291-
$toast.add({
292-
severity: 'error',
293-
summary: t('stop_mining_failed'),
294-
detail: (error as Error).message,
295-
life: 4500,
296-
});
297-
} finally {
298-
isStoppingMining.value = false;
299-
}
300-
}
301-
302286
async function togglePassiveMining(
303287
email: string,
304288
type: string,
@@ -319,6 +303,13 @@ onMounted(async () => {
319303
"sources": "Sources",
320304
"no_sources": "No sources yet",
321305
"email": "Email",
306+
"provider": "Provider",
307+
"last_extraction": "Last extraction",
308+
"continuous_mining": "Continuous mining",
309+
"remove": "Remove",
310+
"remove_source": "Remove source",
311+
"remove_source_confirm": "Remove this mining source permanently? This action cannot be undone.",
312+
"remove_source_failed": "Unable to remove source",
322313
"type": "Type",
323314
"passive_mining": "Passive mining",
324315
"credentials": "Credentials",
@@ -350,6 +341,13 @@ onMounted(async () => {
350341
"sources": "Sources",
351342
"no_sources": "Aucune source",
352343
"email": "Email",
344+
"provider": "Fournisseur",
345+
"last_extraction": "Derniere extraction",
346+
"continuous_mining": "Extraction continue",
347+
"remove": "Retirer",
348+
"remove_source": "Retirer la source",
349+
"remove_source_confirm": "Retirer definitivement cette source de minage ? Cette action est irreversible.",
350+
"remove_source_failed": "Impossible de retirer la source",
353351
"type": "Type",
354352
"passive_mining": "Extraction passive",
355353
"credentials": "Identifiants",

frontend/src/stores/leadminer.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const useLeadminerStore = defineStore('leadminer', () => {
2929

3030
const miningStartedAt = ref<number | undefined>(); // timestamp in performance.now() time (ms)
3131
const miningSources = ref<MiningSource[]>([]);
32+
const isLoadingMiningSources = ref(false);
3233
const boxes = ref<BoxNode[]>([]);
3334
const extractSignatures = ref(true);
3435
const selectedBoxes = ref<TreeSelectionKeys>([]);
@@ -134,11 +135,17 @@ export const useLeadminerStore = defineStore('leadminer', () => {
134135
* @throws {Error} Throws an error if there is an issue while retrieving mining sources.
135136
*/
136137
async function fetchMiningSources() {
137-
miningSources.value =
138-
(await getMiningSources()).map((source) => ({
139-
isValid: true,
140-
...source,
141-
})) ?? [];
138+
isLoadingMiningSources.value = true;
139+
140+
try {
141+
miningSources.value =
142+
(await getMiningSources()).map((source) => ({
143+
isValid: true,
144+
...source,
145+
})) ?? [];
146+
} finally {
147+
isLoadingMiningSources.value = false;
148+
}
142149
}
143150

144151
async function fetchInbox() {
@@ -535,6 +542,7 @@ export const useLeadminerStore = defineStore('leadminer', () => {
535542
miningType,
536543
miningStartedAt,
537544
miningSources,
545+
isLoadingMiningSources,
538546
activeMiningSource,
539547
boxes,
540548
selectedBoxes,

supabase/functions/.env.dev

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
LEADMINER_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
22
LEADMINER_HASH_SECRET=change_me
3-
LEADMINER_PROJECT_URL=http://host.docker.internal:54321/
3+
SUPABASE_PROJECT_URL=http://host.docker.internal:54321/
44
LEADMINER_SECRET_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
55

66
# Use an SMTP testing service like Ethereal for development https://ethereal.email/

supabase/functions/.env.prod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
LEADMINER_ANON_KEY= # ( REQUIRED ) Supabase anon key
22
LEADMINER_HASH_SECRET= # ( REQUIRED ) The hash secret used by backend
3-
LEADMINER_PROJECT_URL= # ( REQUIRED ) Supabase project url
3+
SUPABASE_PROJECT_URL= # ( REQUIRED ) Supabase project url
44
LEADMINER_SECRET_TOKEN= # ( REQUIRED ) Supabase service role key
55

66
SMTP_HOST=

supabase/functions/_shared/url.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {
22
assertEquals,
33
assertThrows,
44
} from "https://deno.land/std@0.224.0/assert/mod.ts";
5-
import { resolvePublicBaseUrl } from "./url.ts";
5+
import { resolveCampaignBaseUrlFromEnv, resolvePublicBaseUrl } from "./url.ts";
66

77
Deno.test("resolvePublicBaseUrl prefers explicit public URL", () => {
88
const value = resolvePublicBaseUrl(
@@ -20,3 +20,22 @@ Deno.test("resolvePublicBaseUrl falls back to secondary URL", () => {
2020
Deno.test("resolvePublicBaseUrl throws when both values are missing", () => {
2121
assertThrows(() => resolvePublicBaseUrl());
2222
});
23+
24+
Deno.test("resolveCampaignBaseUrlFromEnv prefers SUPABASE_PROJECT_URL", () => {
25+
const value = resolveCampaignBaseUrlFromEnv((key) => {
26+
if (key === "SUPABASE_PROJECT_URL") return "https://db-qa.domain.io/";
27+
if (key === "SUPABASE_URL") return "http://kong:8000";
28+
return undefined;
29+
});
30+
31+
assertEquals(value, "https://db-qa.domain.io");
32+
});
33+
34+
Deno.test("resolveCampaignBaseUrlFromEnv falls back to SUPABASE_URL", () => {
35+
const value = resolveCampaignBaseUrlFromEnv((key) => {
36+
if (key === "SUPABASE_URL") return "http://localhost:54321/";
37+
return undefined;
38+
});
39+
40+
assertEquals(value, "http://localhost:54321");
41+
});

supabase/functions/_shared/url.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ function normalizeBaseUrl(url: string): string {
22
return url.replace(/\/+$/, "");
33
}
44

5+
type EnvReader = (key: string) => string | undefined;
6+
57
export function resolvePublicBaseUrl(
68
explicitPublicUrl?: string,
79
fallbackUrl?: string,
@@ -12,3 +14,10 @@ export function resolvePublicBaseUrl(
1214
}
1315
return normalizeBaseUrl(candidate);
1416
}
17+
18+
export function resolveCampaignBaseUrlFromEnv(readEnv: EnvReader): string {
19+
return resolvePublicBaseUrl(
20+
readEnv("SUPABASE_PROJECT_URL"),
21+
readEnv("SUPABASE_URL"),
22+
);
23+
}

0 commit comments

Comments
 (0)