gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[taler-wallet-core] branch master updated: resolve #7751


From: gnunet
Subject: [taler-wallet-core] branch master updated: resolve #7751
Date: Tue, 12 Dec 2023 17:58:49 +0100

This is an automated email from the git hooks/post-receive script.

sebasjm pushed a commit to branch master
in repository wallet-core.

The following commit(s) were added to refs/heads/master by this push:
     new f7e01690b resolve #7751
f7e01690b is described below

commit f7e01690b44e42b7088457374a2a8606fd94f84e
Author: Sebastian <sebasjm@gmail.com>
AuthorDate: Tue Dec 12 13:58:29 2023 -0300

    resolve #7751
---
 packages/demobank-ui/src/Routing.tsx               |   6 +-
 packages/demobank-ui/src/pages/DownloadStats.tsx   | 398 +++++++++++++++++----
 packages/demobank-ui/src/pages/admin/AdminHome.tsx |   4 +-
 3 files changed, 344 insertions(+), 64 deletions(-)

diff --git a/packages/demobank-ui/src/Routing.tsx 
b/packages/demobank-ui/src/Routing.tsx
index 711b7f871..4a250a0d5 100644
--- a/packages/demobank-ui/src/Routing.tsx
+++ b/packages/demobank-ui/src/Routing.tsx
@@ -122,7 +122,11 @@ export function Routing(): VNode {
         />
         <Route
           path="/download-stats"
-          component={() => <DownloadStats />}
+          component={() => <DownloadStats 
+            onCancel={() => {
+              route("/account")
+            }}
+          />}
         />
 
         <Route
diff --git a/packages/demobank-ui/src/pages/DownloadStats.tsx 
b/packages/demobank-ui/src/pages/DownloadStats.tsx
index cd3e6d875..596539e7e 100644
--- a/packages/demobank-ui/src/pages/DownloadStats.tsx
+++ b/packages/demobank-ui/src/pages/DownloadStats.tsx
@@ -14,109 +14,383 @@
  GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
  */
 
-import { AccessToken, Logger, RefreshReason, TalerCoreBankHttpClient, 
TalerCorebankApi, TalerError } from "@gnu-taler/taler-util";
-import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { AccessToken, AmountString, Logger, TalerCoreBankHttpClient, 
TalerCorebankApi, TalerError } from "@gnu-taler/taler-util";
+import { Attention, LocalNotificationBanner, useLocalNotification, 
useTranslationContext } from "@gnu-taler/web-util/browser";
 import { Fragment, VNode, h } from "preact";
 import { useState } from "preact/hooks";
-import { Loading } from "@gnu-taler/web-util/browser";
-import { Transactions } from "../components/Transactions/index.js";
-import { usePublicAccounts } from "../hooks/access.js";
-import { useBackendState } from "../hooks/backend.js";
 import { useBankCoreApiContext } from "../context/config.js";
+import { useBackendState } from "../hooks/backend.js";
 import { getTimeframesForDate } from "./admin/AdminHome.js";
 
 const logger = new Logger("PublicHistoriesPage");
 
-interface Props { }
+interface Props { 
+  onCancel: () => void;
+}
+
+type Options = {
+  dayMetric: boolean;
+  hourMetric: boolean;
+  monthMetric: boolean;
+  yearMetric: boolean;
+  compareWithPrevious: boolean;
+  endOnFirstFail: boolean;
+  includeHeader: boolean;
+}
 
 /** 
  * Show histories of public accounts.
  */
-export function DownloadStats({ }: Props): VNode {
+export function DownloadStats({ onCancel }: Props): VNode {
   const { i18n } = useTranslationContext();
 
   const { state: credentials } = useBackendState();
   const creds = credentials.status !== "loggedIn" || 
!credentials.isUserAdministrator ? undefined : credentials
-  const { api, config } = useBankCoreApiContext();
+  const { api } = useBankCoreApiContext();
 
-  const [state, setState] = useState<number>()
+  const [options, setOptions] = useState<Options>({
+    compareWithPrevious: true,
+    dayMetric: true,
+    endOnFirstFail: false,
+    hourMetric: true,
+    includeHeader: true,
+    monthMetric: true,
+    yearMetric: true,
+  })
+  const [lastStep, setLastStep] = useState<{ step: number, total: number }>()
+  const [downloaded, setDownloaded] = useState<string>()
+  const referenceDates = [new Date()]
+  const [notification, notify, handleError] = useLocalNotification()
 
   if (!creds) {
     return <div>only admin can download stats</div>
   }
 
   return (
-    <Fragment>
-      <h1 class="nav">{i18n.str`Stats`}</h1>
-      <button type="button"
-        class="inline-flex items-center  disabled:opacity-50 
disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 
text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 
focus-visible:outline-indigo-600"
-        onClick={() => {
-          fetchAllStatus(api, creds.token, new Date(), (p, total) => {
-            console.log("doing...", total - p)
-            setState(total - p)
-          })
-        }}
-      >
-        start
-      </button>
-      progress {state}
-    </Fragment>
+    <div>
+
+      <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 
bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+        <LocalNotificationBanner notification={notification} />
+
+        <div class="px-4 sm:px-0">
+          <h2 class="text-base font-semibold leading-7 text-gray-900">
+            <i18n.Translate>Download bank stats</i18n.Translate>
+          </h2>
+        </div>
+
+
+        <form
+          class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl 
md:col-span-2"
+          autoCapitalize="none"
+          autoCorrect="off"
+          onSubmit={e => {
+            e.preventDefault()
+          }}
+        >
+          <div class="px-4 py-6 sm:p-8">
+            <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 
sm:grid-cols-6">
+              <div class="sm:col-span-5">
+                <div class="flex items-center justify-between">
+                  <span class="flex flex-grow flex-col">
+                    <span class="text-sm text-black font-medium leading-6 " 
id="availability-label">
+                      <i18n.Translate>Include hour metric</i18n.Translate>
+                    </span>
+                  </span>
+                  <button type="button" data-enabled={options.hourMetric} 
class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 
w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent 
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 
focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" 
aria-labelledby="availability-label" aria-describedby="availability-description"
+
+                    onClick={() => { setOptions({ ...options, hourMetric: 
!options.hourMetric }) }}>
+                    <span aria-hidden="true" data-enabled={options.hourMetric} 
class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none 
inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition 
duration-200 ease-in-out"></span>
+                  </button>
+                </div>
+              </div>
+              <div class="sm:col-span-5">
+                <div class="flex items-center justify-between">
+                  <span class="flex flex-grow flex-col">
+                    <span class="text-sm text-black font-medium leading-6 " 
id="availability-label">
+                      <i18n.Translate>Include day metric</i18n.Translate>
+                    </span>
+                  </span>
+                  <button type="button" data-enabled={!!options.dayMetric} 
class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 
w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent 
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 
focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" 
aria-labelledby="availability-label" aria-describedby="availability-description"
+
+                    onClick={() => { setOptions({ ...options, dayMetric: 
!options.dayMetric }) }}>
+                    <span aria-hidden="true" data-enabled={options.dayMetric} 
class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none 
inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition 
duration-200 ease-in-out"></span>
+                  </button>
+                </div>
+              </div>
+              <div class="sm:col-span-5">
+                <div class="flex items-center justify-between">
+                  <span class="flex flex-grow flex-col">
+                    <span class="text-sm text-black font-medium leading-6 " 
id="availability-label">
+                      <i18n.Translate>Include month metric</i18n.Translate>
+                    </span>
+                  </span>
+                  <button type="button" data-enabled={!!options.monthMetric} 
class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 
w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent 
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 
focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" 
aria-labelledby="availability-label" aria-describedby="availability-description"
+
+                    onClick={() => { setOptions({ ...options, monthMetric: 
!options.monthMetric }) }}>
+                    <span aria-hidden="true" 
data-enabled={options.monthMetric} class="translate-x-5 
data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 
transform rounded-full bg-white shadow ring-0 transition duration-200 
ease-in-out"></span>
+                  </button>
+                </div>
+              </div>
+              <div class="sm:col-span-5">
+                <div class="flex items-center justify-between">
+                  <span class="flex flex-grow flex-col">
+                    <span class="text-sm text-black font-medium leading-6 " 
id="availability-label">
+                      <i18n.Translate>Include year metric</i18n.Translate>
+                    </span>
+                  </span>
+                  <button type="button" data-enabled={!!options.yearMetric} 
class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 
w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent 
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 
focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" 
aria-labelledby="availability-label" aria-describedby="availability-description"
+
+                    onClick={() => { setOptions({ ...options, yearMetric: 
!options.yearMetric }) }}>
+                    <span aria-hidden="true" data-enabled={options.yearMetric} 
class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none 
inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition 
duration-200 ease-in-out"></span>
+                  </button>
+                </div>
+              </div>
+              <div class="sm:col-span-5">
+                <div class="flex items-center justify-between">
+                  <span class="flex flex-grow flex-col">
+                    <span class="text-sm text-black font-medium leading-6 " 
id="availability-label">
+                      <i18n.Translate>Include table header</i18n.Translate>
+                    </span>
+                  </span>
+                  <button type="button" data-enabled={!!options.includeHeader} 
class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 
w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent 
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 
focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" 
aria-labelledby="availability-label" aria-describedby="availability-description"
+
+                    onClick={() => { setOptions({ ...options, includeHeader: 
!options.includeHeader }) }}>
+                    <span aria-hidden="true" 
data-enabled={options.includeHeader} class="translate-x-5 
data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 
transform rounded-full bg-white shadow ring-0 transition duration-200 
ease-in-out"></span>
+                  </button>
+                </div>
+              </div>
+              <div class="sm:col-span-5">
+                <div class="flex items-center justify-between">
+                  <span class="flex flex-grow flex-col">
+                    <span class="text-sm text-black font-medium leading-6 " 
id="availability-label">
+                      <i18n.Translate>Add previous metric for 
compare</i18n.Translate>
+                    </span>
+                  </span>
+                  <button type="button" 
data-enabled={!!options.compareWithPrevious} class="bg-indigo-600 
data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 
cursor-pointer rounded-full border-2 border-transparent transition-colors 
duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 
focus:ring-offset-2" role="switch" aria-checked="false" 
aria-labelledby="availability-label" aria-describedby="availability-description"
+
+                    onClick={() => { setOptions({ ...options, 
compareWithPrevious: !options.compareWithPrevious }) }}>
+                    <span aria-hidden="true" 
data-enabled={options.compareWithPrevious} class="translate-x-5 
data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 
transform rounded-full bg-white shadow ring-0 transition duration-200 
ease-in-out"></span>
+                  </button>
+                </div>
+              </div>
+              <div class="sm:col-span-5">
+                <div class="flex items-center justify-between">
+                  <span class="flex flex-grow flex-col">
+                    <span class="text-sm text-black font-medium leading-6 " 
id="availability-label">
+                      <i18n.Translate>Fail on first error</i18n.Translate>
+                    </span>
+                  </span>
+                  <button type="button" 
data-enabled={!!options.endOnFirstFail} class="bg-indigo-600 
data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 
cursor-pointer rounded-full border-2 border-transparent transition-colors 
duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 
focus:ring-offset-2" role="switch" aria-checked="false" 
aria-labelledby="availability-label" aria-describedby="availability-description"
+
+                    onClick={() => { setOptions({ ...options, endOnFirstFail: 
!options.endOnFirstFail }) }}>
+                    <span aria-hidden="true" 
data-enabled={options.endOnFirstFail} class="translate-x-5 
data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 
transform rounded-full bg-white shadow ring-0 transition duration-200 
ease-in-out"></span>
+                  </button>
+                </div>
+              </div>
+            </div>
+          </div>
+
+
+
+          <div class="flex items-center justify-between gap-x-6 border-t 
border-gray-900/10 px-4 py-4 sm:px-8">
+            <button type="button" class="text-sm font-semibold leading-6 
text-gray-900"
+              onClick={onCancel}
+            >
+              <i18n.Translate>Cancel</i18n.Translate>
+            </button>
+            <button type="submit"
+              class="disabled:opacity-50 disabled:cursor-default 
cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold 
text-white shadow-sm hover:bg-indigo-500 focus-visible:outline 
focus-visible:outline-2 focus-visible:outline-offset-2 
focus-visible:outline-indigo-600"
+              disabled={lastStep !== undefined}
+              onClick={async () => {
+                setDownloaded(undefined)
+                await handleError(async () => {
+                  const csv = await fetchAllStatus(api, creds.token, options, 
referenceDates, (step, total) => {
+                    setLastStep({ step, total })
+                  })
+                  setDownloaded(csv)
+                })
+                setLastStep(undefined)
+              }}
+            >
+              <i18n.Translate>Download</i18n.Translate>
+            </button>
+          </div>
+        </form>
+
+      </div>
+      {!lastStep || lastStep.step === lastStep.total ? <div class="h-5 mb-5"/> 
: <div>
+        <div class="relative mb-5 h-5 rounded-full bg-gray-200">
+          <div class="h-full animate-pulse rounded-full bg-blue-500" style={{
+            width: `${Math.round((((lastStep.step / lastStep.total))* 100) )}%`
+          }}>
+            <span class="absolute inset-0 flex items-center justify-center 
text-xs font-semibold text-white">
+              <i18n.Translate>downloading... {Math.round((((lastStep.step / 
lastStep.total))* 100) )}</i18n.Translate>
+            </span>
+          </div>
+        </div>
+      </div>}
+      {!downloaded ? <div class="h-5 mb-5"/> :
+        <a href={"data:text/plain;charset=utf-8," + 
encodeURIComponent(downloaded)} download={"bank-stats.csv"}>
+          <Attention title={i18n.str`Download completed`}>
+            <i18n.Translate>click here to save the file in your 
computer</i18n.Translate>
+          </Attention>
+        </a>
+      }
+    </div>
   );
 }
 
 
-async function fetchAllStatus(api: TalerCoreBankHttpClient, token: 
AccessToken, reference: Date, progres: (current: number, total: number) => 
void) {
-  const allMetrics = [
-    TalerCorebankApi.MonitorTimeframeParam.day,
-    TalerCorebankApi.MonitorTimeframeParam.hour,
-    // TalerCorebankApi.MonitorTimeframeParam.month,
-    // TalerCorebankApi.MonitorTimeframeParam.year,
-    // TalerCorebankApi.MonitorTimeframeParam.decade,
-  ]
-  const allFrames = allMetrics.map(timeframe => ({
-    timeframe, moment: getTimeframesForDate(reference, timeframe)
-  }))
-
-  type Data = {
-    previous: TalerCorebankApi.MonitorResponse;
-    current: TalerCorebankApi.MonitorResponse;
+async function fetchAllStatus(api: TalerCoreBankHttpClient, token: 
AccessToken, options: Options, references: Date[], progres: (current: number, 
total: number) => void): Promise<string> {
+  const allMetrics: TalerCorebankApi.MonitorTimeframeParam[] = [];
+  if (options.hourMetric) {
+    allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.hour)
+  }
+  if (options.dayMetric) {
+    allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.day)
   }
+  if (options.monthMetric) {
+    allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.month)
+  }
+  if (options.yearMetric) {
+    allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.year)
+  }
+
+  /**
+   * conver request into frames
+   */
+  const allFrames = allMetrics.flatMap(timeframe => references.map(reference 
=> ({
+    reference,
+    timeframe,
+    moment: getTimeframesForDate(reference, timeframe)
+  }))
+  )
   const total = allFrames.length
-  
-  const dataResolvers = allFrames.map((frame, index) => async function 
getData(): Promise<Data | undefined> {
-    const previous = await api.getMonitor(token, {
+
+  /**
+   * call API for info
+   */
+  const allInfo = await allFrames.reduce(async (prev, frame, index) => {
+    const accumulatedMap = await prev
+    progres(index, total)
+    // await delay()
+    const previous = options.compareWithPrevious ? (await 
api.getMonitor(token, {
       timeframe: frame.timeframe,
       which: frame.moment.previous
-    })
-    await delay()
-    if (previous.type !== "ok") return undefined;
+    })) : undefined
+
+    if (previous && previous.type === "fail" && options.endOnFirstFail) {
+      throw TalerError.fromUncheckedDetail(previous.detail)
+    }
+
     const current = await api.getMonitor(token, {
       timeframe: frame.timeframe,
       which: frame.moment.current
     })
-    await delay()
-    if (current.type !== "ok") return undefined;
-    return { previous: previous.body, current: current.body }
-  });
 
-  const csv = await dataResolvers.reduce(async (prev, resolver, index) => {
-    const accumulatedMap = await prev
-    console.log(index)
-    progres(index, total)
-    const data = await resolver()
-    if (!data) return accumulatedMap
+    if (current.type === "fail" && options.endOnFirstFail) {
+      throw TalerError.fromUncheckedDetail(current.detail)
+    }
 
     const metricName = 
TalerCorebankApi.MonitorTimeframeParam[allMetrics[index]]
-    accumulatedMap[metricName] = data
+    accumulatedMap[metricName] = {
+      reference: frame.reference,
+      current: current.type !== "ok" ? undefined : current.body,
+      previous: !previous || previous.type !== "ok" ? undefined : 
previous.body,
+    }
     return accumulatedMap
   }, Promise.resolve({} as Record<string, Data>))
   progres(total, total)
-  console.log(csv)
+
+  /**
+   * conver into table format
+   * 
+   */
+  const table: Array<string[]> = [];
+  if (options.includeHeader) {
+    table.push(["date",
+      "metric",
+      "reference",
+      "talerInCount",
+      "talerInVolume",
+      "talerOutCount",
+      "talerOutVolume",
+      "cashinCount",
+      "cashinFiatVolume",
+      "cashinRegionalVolume",
+      "cashoutCount",
+      "cashoutFiatVolume",
+      "cashoutRegionalVolume",])
+  }
+  Object.entries(allInfo).forEach(([name, data]) => {
+    if (data.current) {
+      const row: TableRow = {
+        date: data.reference.getTime(),
+        metric: name,
+        reference: "current",
+        ...dataToRow(data.current)
+      }
+      table.push((Object.values(row) as string[]))
+    }
+
+    if (data.previous) {
+      const row: TableRow = {
+        date: data.reference.getTime(),
+        metric: name,
+        reference: "previous",
+        ...dataToRow(data.previous)
+      }
+      table.push((Object.values(row) as string[]))
+    }
+  })
+
+  const csv = table.reduce((acc, row) => {
+    return acc + row.join(",") + "\n"
+  }, "")
+
+  return csv
+}
+
+type JustData = Omit<Omit<Omit<TableRow, "metric">, "date">, "reference">
+function dataToRow(info: TalerCorebankApi.MonitorResponse): JustData {
+  return {
+    talerInCount: info.talerInCount,
+    talerInVolume: info.talerInVolume,
+    talerOutCount: info.talerOutCount,
+    talerOutVolume: info.talerOutVolume,
+    cashinCount: info.type === "no-conversions" ? undefined : info.cashinCount,
+    cashinFiatVolume: info.type === "no-conversions" ? undefined : 
info.cashinFiatVolume,
+    cashinRegionalVolume: info.type === "no-conversions" ? undefined : 
info.cashinRegionalVolume,
+    cashoutCount: info.type === "no-conversions" ? undefined : 
info.cashoutCount,
+    cashoutFiatVolume: info.type === "no-conversions" ? undefined : 
info.cashoutFiatVolume,
+    cashoutRegionalVolume: info.type === "no-conversions" ? undefined : 
info.cashoutRegionalVolume,
+  }
+}
+
+type Data = {
+  reference: Date,
+  previous: TalerCorebankApi.MonitorResponse | undefined;
+  current: TalerCorebankApi.MonitorResponse | undefined;
+}
+type TableRow = {
+  date: number,
+  metric: string,
+  reference: "current" | "previous",
+  cashinCount?: number;
+  cashinRegionalVolume?: AmountString;
+  cashinFiatVolume?: AmountString;
+  cashoutCount?: number;
+  cashoutRegionalVolume?: AmountString;
+  cashoutFiatVolume?: AmountString;
+  talerInCount: number;
+  talerInVolume: AmountString;
+  talerOutCount: number;
+  talerOutVolume: AmountString;
 }
 async function delay() {
-  return new Promise((res, rej) => {
-    setTimeout(() => {
-      res(null);
-    }, 1000)
+  return new Promise(res => {
+    setTimeout(( )=> {
+      res(null)
+    }, 500)
   })
 }
\ No newline at end of file
diff --git a/packages/demobank-ui/src/pages/admin/AdminHome.tsx 
b/packages/demobank-ui/src/pages/admin/AdminHome.tsx
index 9c6e6cde6..82a341dbe 100644
--- a/packages/demobank-ui/src/pages/admin/AdminHome.tsx
+++ b/packages/demobank-ui/src/pages/admin/AdminHome.tsx
@@ -184,7 +184,9 @@ function Metrics(): VNode {
       </div>
     </dl>
     <div class="flex justify-end mt-2">
-      <a href="#/download-stats" class="link"><i18n.Translate>
+      <a href="#/download-stats" 
+              class="disabled:opacity-50 disabled:cursor-default 
cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold 
text-white shadow-sm hover:bg-indigo-500 focus-visible:outline 
focus-visible:outline-2 focus-visible:outline-offset-2 
focus-visible:outline-indigo-600"
+              ><i18n.Translate>
         download stats as csv
       </i18n.Translate></a>
     </div>

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]