diff --git a/components/layout/GridLayout.js b/components/layout/GridLayout.js index 01ec1e8f..1d3170d6 100644 --- a/components/layout/GridLayout.js +++ b/components/layout/GridLayout.js @@ -13,7 +13,7 @@ export const GridRow = ({ className, children }) => { export const GridColumn = ({ xs, sm, md, lg, xl, className, children }) => { const classes = []; - classes.push(xs ? `col-${xs}` : 'col'); + classes.push(xs ? `col-${xs}` : 'col-12'); if (sm) { classes.push(`col-sm-${sm}`); diff --git a/components/metrics/DataTable.js b/components/metrics/DataTable.js new file mode 100644 index 00000000..9e12d887 --- /dev/null +++ b/components/metrics/DataTable.js @@ -0,0 +1,91 @@ +import React, { useState } from 'react'; +import { FixedSizeList } from 'react-window'; +import { useSpring, animated, config } from 'react-spring'; +import classNames from 'classnames'; +import NoData from 'components/common/NoData'; +import { formatNumber, formatLongNumber } from 'lib/format'; +import styles from './DataTable.module.css'; + +export default function DataTable({ + data, + title, + metric, + className, + limit, + renderLabel, + height = 400, + animate = true, +}) { + const [format, setFormat] = useState(true); + const formatFunc = format ? formatLongNumber : formatNumber; + + const handleSetFormat = () => setFormat(state => !state); + + const getRow = row => { + const { x: label, y: value, z: percent } = row; + + return ( + + ); + }; + + const Row = ({ index, style }) => { + return
{getRow(data[index])}
; + }; + + return ( +
+
+
{title}
+
+ {metric} +
+
+
+ {data?.length === 0 && } + {limit + ? data.map(row => getRow(row)) + : data.length > 0 && ( + + {Row} + + )} +
+
+ ); +} + +const AnimatedRow = ({ label, value = 0, percent, animate, format, onClick }) => { + const props = useSpring({ + width: percent, + y: value, + from: { width: 0, y: 0 }, + config: animate ? config.default : { duration: 0 }, + }); + + return ( +
+
{label}
+
+ {props.y?.interpolate(format)} +
+
+ `${n}%`) }} + /> + + {props.width.interpolate(n => `${n.toFixed(0)}%`)} + +
+
+ ); +}; diff --git a/components/metrics/DataTable.module.css b/components/metrics/DataTable.module.css new file mode 100644 index 00000000..b276e361 --- /dev/null +++ b/components/metrics/DataTable.module.css @@ -0,0 +1,96 @@ +.table { + position: relative; + font-size: var(--font-size-small); + display: flex; + flex-direction: column; + flex: 1; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + line-height: 40px; +} + +.title { + display: flex; + font-weight: 600; + font-size: var(--font-size-normal); +} + +.metric { + font-size: var(--font-size-small); + font-weight: 600; + text-align: center; + width: 100px; + cursor: pointer; +} + +.row { + position: relative; + height: 30px; + line-height: 30px; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; + overflow: hidden; +} + +.label { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + flex: 2; +} + +.label a { + color: inherit; + text-decoration: none; +} + +.label a:hover { + color: var(--primary400); +} + +.label:empty { + color: #b3b3b3; +} + +.label:empty:before { + content: 'Unknown'; +} + +.value { + width: 50px; + text-align: right; + margin-right: 10px; + font-weight: 600; + cursor: pointer; +} + +.percent { + position: relative; + width: 50px; + color: var(--gray600); + border-left: 1px solid var(--gray600); + padding-left: 10px; + z-index: 1; +} + +.bar { + position: absolute; + top: 0; + left: 0; + height: 30px; + opacity: 0.1; + background: var(--primary400); + z-index: -1; +} + +.body { + position: relative; + flex: 1; + overflow: hidden; +} diff --git a/components/metrics/MetricsBar.js b/components/metrics/MetricsBar.js index 9fbc3054..3b159327 100644 --- a/components/metrics/MetricsBar.js +++ b/components/metrics/MetricsBar.js @@ -18,8 +18,6 @@ export default function MetricsBar({ websiteId, token, className }) { query: { url }, } = usePageQuery(); - console.log({ modified }); - const { data, error, loading } = useFetch( `/api/website/${websiteId}/stats`, { diff --git a/components/metrics/MetricsTable.js b/components/metrics/MetricsTable.js index 2f508295..d4b246b3 100644 --- a/components/metrics/MetricsTable.js +++ b/components/metrics/MetricsTable.js @@ -1,19 +1,17 @@ -import React, { useState, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { FormattedMessage } from 'react-intl'; -import { FixedSizeList } from 'react-window'; -import { useSpring, animated, config } from 'react-spring'; import classNames from 'classnames'; import Link from 'components/common/Link'; import Loading from 'components/common/Loading'; -import NoData from 'components/common/NoData'; import useFetch from 'hooks/useFetch'; import Arrow from 'assets/arrow-right.svg'; import { percentFilter } from 'lib/filters'; -import { formatNumber, formatLongNumber } from 'lib/format'; import useDateRange from 'hooks/useDateRange'; import usePageQuery from 'hooks/usePageQuery'; +import ErrorMessage from 'components/common/ErrorMessage'; +import DataTable from './DataTable'; +import { DEFAULT_ANIMATION_DURATION } from 'lib/constants'; import styles from './MetricsTable.module.css'; -import ErrorMessage from '../common/ErrorMessage'; export default function MetricsTable({ websiteId, @@ -49,15 +47,12 @@ export default function MetricsTable({ token, }, onDataLoad, - delay: 300, + delay: DEFAULT_ANIMATION_DURATION, }, [modified], ); - const [format, setFormat] = useState(true); - const formatFunc = format ? formatLongNumber : formatNumber; - const shouldAnimate = limit > 0; - const rankings = useMemo(() => { + const filteredData = useMemo(() => { if (data) { const items = percentFilter(dataFilter ? dataFilter(data, filterOptions) : data); if (limit) { @@ -68,91 +63,34 @@ export default function MetricsTable({ return []; }, [data, error, dataFilter, filterOptions]); - const handleSetFormat = () => setFormat(state => !state); - - const getRow = row => { - const { x: label, y: value, z: percent } = row; - return ( - - ); - }; - - const Row = ({ index, style }) => { - return
{getRow(rankings[index])}
; - }; - return (
{!data && loading && } {error && } {data && !error && ( - <> -
-
{title}
-
- {metric} -
-
-
- {rankings?.length === 0 && } - {limit - ? rankings.map(row => getRow(row)) - : rankings.length > 0 && ( - - {Row} - - )} -
-
- {limit && ( - } - href={router.pathname} - as={resolve({ view: type })} - size="small" - iconRight - > - - - )} -
- + 0} + /> )} +
+ {limit && ( + } + href={router.pathname} + as={resolve({ view: type })} + size="small" + iconRight + > + + + )} +
); } - -const AnimatedRow = ({ label, value = 0, percent, animate, format, onClick }) => { - const props = useSpring({ - width: percent, - y: value, - from: { width: 0, y: 0 }, - config: animate ? config.default : { duration: 0 }, - }); - - return ( -
-
{label}
-
- {props.y?.interpolate(format)} -
-
- `${n}%`) }} - /> - - {props.width.interpolate(n => `${n.toFixed(0)}%`)} - -
-
- ); -}; diff --git a/components/metrics/MetricsTable.module.css b/components/metrics/MetricsTable.module.css index bbba0009..e93f536e 100644 --- a/components/metrics/MetricsTable.module.css +++ b/components/metrics/MetricsTable.module.css @@ -6,95 +6,6 @@ flex-direction: column; } -.header { - display: flex; - align-items: center; - justify-content: space-between; - line-height: 40px; -} - -.title { - display: flex; - font-weight: 600; - font-size: var(--font-size-normal); -} - -.metric { - font-size: var(--font-size-small); - font-weight: 600; - text-align: center; - width: 100px; - cursor: pointer; -} - -.row { - position: relative; - height: 30px; - line-height: 30px; - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 5px; - overflow: hidden; -} - -.label { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - flex: 2; -} - -.label a { - color: inherit; - text-decoration: none; -} - -.label a:hover { - color: var(--primary400); -} - -.label:empty { - color: #b3b3b3; -} - -.label:empty:before { - content: 'Unknown'; -} - -.value { - width: 50px; - text-align: right; - margin-right: 10px; - font-weight: 600; - cursor: pointer; -} - -.percent { - position: relative; - width: 50px; - color: var(--gray600); - border-left: 1px solid var(--gray600); - padding-left: 10px; - z-index: 1; -} - -.bar { - position: absolute; - top: 0; - left: 0; - height: 30px; - opacity: 0.1; - background: var(--primary400); - z-index: -1; -} - -.body { - position: relative; - flex: 1; - overflow: hidden; -} - .footer { display: flex; justify-content: center; diff --git a/components/metrics/RealtimeHeader.js b/components/metrics/RealtimeHeader.js index 09f83ff6..deea9387 100644 --- a/components/metrics/RealtimeHeader.js +++ b/components/metrics/RealtimeHeader.js @@ -10,17 +10,7 @@ export default function RealtimeHeader({ websites, data, websiteId, onSelect }) { label: , value: 0 }, ].concat(websites.map(({ name, website_id }) => ({ label: name, value: website_id }))); - const { pageviews, sessions, events } = data; - const countries = sessions.reduce((obj, { country }) => { - if (country) { - if (!obj[country]) { - obj[country] = 1; - } else { - obj[country] += 1; - } - } - return obj; - }, {}); + const { pageviews, sessions, events, countries } = data; return ( <> @@ -45,7 +35,7 @@ export default function RealtimeHeader({ websites, data, websiteId, onSelect }) /> } - value={Object.keys(countries).length} + value={countries.length} /> diff --git a/components/pages/RealtimeDashboard.js b/components/pages/RealtimeDashboard.js index 2b878f1a..5c8f052a 100644 --- a/components/pages/RealtimeDashboard.js +++ b/components/pages/RealtimeDashboard.js @@ -1,5 +1,7 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { subMinutes, startOfMinute } from 'date-fns'; +import firstBy from 'thenby'; +import { percentFilter } from 'lib/filters'; import Page from 'components/layout/Page'; import GridLayout, { GridRow, GridColumn } from 'components/layout/GridLayout'; import RealtimeChart from '../metrics/RealtimeChart'; @@ -7,6 +9,11 @@ import RealtimeLog from '../metrics/RealtimeLog'; import styles from './RealtimeDashboard.module.css'; import RealtimeHeader from '../metrics/RealtimeHeader'; import useFetch from 'hooks/useFetch'; +import WorldMap from '../common/WorldMap'; +import DataTable from '../metrics/DataTable'; +import useLocale from 'hooks/useLocale'; +import useCountryNames from 'hooks/useCountryNames'; +import { FormattedMessage } from 'react-intl'; const REALTIME_RANGE = 30; const REALTIME_INTERVAL = 5000; @@ -23,6 +30,8 @@ function filterWebsite(data, id) { } export default function RealtimeDashboard() { + const [locale] = useLocale(); + const countryNames = useCountryNames(locale); const [data, setData] = useState(); const [websiteId, setWebsiteId] = useState(0); const { data: init, loading } = useFetch('/api/realtime', { params: { type: 'init' } }); @@ -33,41 +42,84 @@ export default function RealtimeDashboard() { headers: { 'x-umami-token': init?.token }, }); - const realtimeData = useMemo(() => { - if (websiteId) { - const { pageviews, sessions, events, ...props } = data; - const countries = sessions.reduce((obj, { country }) => { - if (country) { - if (!obj[country]) { - obj[country] = 1; - } else { - obj[country] += 1; - } - } - return obj; - }, {}); + const renderCountryName = useCallback(({ x }) => countryNames[x], []); - return { - pageviews: filterWebsite(pageviews, websiteId), - sessions: filterWebsite(sessions, websiteId), - events: filterWebsite(events, websiteId), - countries, - ...props, - }; + const realtimeData = useMemo(() => { + if (data) { + const { pageviews, sessions, events } = data; + + if (websiteId) { + return { + pageviews: filterWebsite(pageviews, websiteId), + sessions: filterWebsite(sessions, websiteId), + events: filterWebsite(events, websiteId), + }; + } } + return data; }, [data, websiteId]); + const countries = useMemo(() => { + if (realtimeData?.sessions) { + return percentFilter( + realtimeData.sessions + .reduce((arr, { country }) => { + if (country) { + const row = arr.find(({ x }) => x === country); + + if (!row) { + arr.push({ x: country, y: 1 }); + } else { + row.y += 1; + } + } + return arr; + }, []) + .sort(firstBy('y', -1)), + ); + } + return []; + }, [realtimeData]); + + const referrers = useMemo(() => { + if (realtimeData?.pageviews) { + return percentFilter( + realtimeData.pageviews + .reduce((arr, { referrer }) => { + if (referrer?.startsWith('http')) { + const { hostname } = new URL(referrer); + const row = arr.find(({ x }) => x === hostname); + + if (!row) { + arr.push({ x: hostname, y: 1 }); + } else { + row.y += 1; + } + } + return arr; + }, []) + .sort(firstBy('y', -1)), + ); + } + return []; + }, [realtimeData]); + useEffect(() => { if (init && !data) { - setData(init.data); + const { websites, data } = init; + const domains = init.websites.map(({ domain }) => domain); + + setData({ websites, domains, ...data }); } else if (updates) { const { pageviews, sessions, events, timestamp } = updates; - const minTime = subMinutes(startOfMinute(new Date()), REALTIME_RANGE).getTime(); + const time = subMinutes(startOfMinute(new Date()), REALTIME_RANGE).getTime(); + setData(state => ({ - pageviews: mergeData(state.pageviews, pageviews, minTime), - sessions: mergeData(state.sessions, sessions, minTime), - events: mergeData(state.events, events, minTime), + ...state, + pageviews: mergeData(state.pageviews, pageviews, time), + sessions: mergeData(state.sessions, sessions, time), + events: mergeData(state.events, events, time), timestamp, })); } @@ -77,14 +129,16 @@ export default function RealtimeDashboard() { return null; } - const { websites } = init; + const { websites } = data; + + //console.log({realtimeData, countries}); return (
@@ -101,15 +155,26 @@ export default function RealtimeDashboard() { - x + } + metric={} + data={referrers} + animate={false} + /> - x + } + metric={} + data={countries} + renderLabel={renderCountryName} + animate={false} + /> - x + diff --git a/lang/da-DK.json b/lang/da-DK.json index 5532bf69..b66b513d 100644 --- a/lang/da-DK.json +++ b/lang/da-DK.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Adgangskoder matcher ikke", "label.profile": "Profil", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Påkrævet", "label.settings": "Indstillinger", "label.this-month": "Denne måned", diff --git a/lang/de-DE.json b/lang/de-DE.json index df5e6dd1..4447f98b 100644 --- a/lang/de-DE.json +++ b/lang/de-DE.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Passwörter stimmen nicht überein", "label.profile": "Profil", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Erforderlich", "label.settings": "Einstellungen", "label.this-month": "Diesen Monat", diff --git a/lang/el-GR.json b/lang/el-GR.json index 7fbdbfbf..978de681 100644 --- a/lang/el-GR.json +++ b/lang/el-GR.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Οι κωδικοί πρόσβασης δεν ταιριάζουν", "label.profile": "Προφίλ", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Απαιτείται", "label.settings": "Ρυθμίσεις", "label.this-month": "Αυτο το μήνα", diff --git a/lang/en-US.json b/lang/en-US.json index 07670232..12231157 100644 --- a/lang/en-US.json +++ b/lang/en-US.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Passwords don't match", "label.profile": "Profile", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Required", "label.settings": "Settings", "label.this-month": "This month", diff --git a/lang/es-MX.json b/lang/es-MX.json index 61395fcf..eaf92db6 100644 --- a/lang/es-MX.json +++ b/lang/es-MX.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Las contraseñas no coinciden", "label.profile": "Perfil", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Requerido", "label.settings": "Configuraciones", "label.this-month": "Este mes", diff --git a/lang/fi-FI.json b/lang/fi-FI.json index 9f0c930b..fa808980 100644 --- a/lang/fi-FI.json +++ b/lang/fi-FI.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Salasanat eivät täsmää", "label.profile": "Profiili", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Vaaditaan", "label.settings": "Asetukset", "label.this-month": "Tämä kuukausi", diff --git a/lang/fo-FO.json b/lang/fo-FO.json index ee5ab8cb..8cedbcdb 100644 --- a/lang/fo-FO.json +++ b/lang/fo-FO.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Loyniorðini eru ikki eins", "label.profile": "Brúkari", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Krav", "label.settings": "Stillingar", "label.this-month": "Hendan mánan", diff --git a/lang/fr-FR.json b/lang/fr-FR.json index cb2cc103..ff00653d 100644 --- a/lang/fr-FR.json +++ b/lang/fr-FR.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Les mots de passe ne correspondent pas", "label.profile": "Profile", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Requis", "label.settings": "Paramètres", "label.this-month": "Ce mois ci", diff --git a/lang/id-ID.json b/lang/id-ID.json index 7c7db298..9c05cf80 100644 --- a/lang/id-ID.json +++ b/lang/id-ID.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Kata sandi tidak cocok", "label.profile": "Profil", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Wajib", "label.settings": "Pengaturan", "label.this-month": "Bulan ini", diff --git a/lang/ja-JP.json b/lang/ja-JP.json index 09458224..3a019f27 100644 --- a/lang/ja-JP.json +++ b/lang/ja-JP.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "パスワードが一致しません", "label.profile": "プロファイル", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "必須", "label.settings": "設定", "label.this-month": "今月", diff --git a/lang/mn-MN.json b/lang/mn-MN.json index 5e92c9c7..6a9a0c0b 100644 --- a/lang/mn-MN.json +++ b/lang/mn-MN.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Нууц үг тохирохгүй байна", "label.profile": "Бүртгэл", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Шаардлагатай", "label.settings": "Тохиргоо", "label.this-month": "Энэ сар", diff --git a/lang/nb-NO.json b/lang/nb-NO.json index 0af11471..0988f361 100644 --- a/lang/nb-NO.json +++ b/lang/nb-NO.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Passordene er ikke like", "label.profile": "Profil", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Påkrevd", "label.settings": "Innstillinger", "label.this-month": "Denne måneden", diff --git a/lang/nl-NL.json b/lang/nl-NL.json index 4613ed33..f36289cc 100644 --- a/lang/nl-NL.json +++ b/lang/nl-NL.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Wachtwoorden komen niet overeen", "label.profile": "Profiel", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Verplicht", "label.settings": "Instellingen", "label.this-month": "Deze maand", diff --git a/lang/pt-PT.json b/lang/pt-PT.json index 81e3a334..d1f7ee3c 100644 --- a/lang/pt-PT.json +++ b/lang/pt-PT.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Palavra-passes não correspondem", "label.profile": "Perfil", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Obrigatório", "label.settings": "Definições", "label.this-month": "Este mês", diff --git a/lang/ro-RO.json b/lang/ro-RO.json index 7e2f4800..3fe2fb92 100644 --- a/lang/ro-RO.json +++ b/lang/ro-RO.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Parolele nu se potrivesc", "label.profile": "Profil", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Obligatoriu", "label.settings": "Setări", "label.this-month": "Această lună", diff --git a/lang/ru-RU.json b/lang/ru-RU.json index 8a1ffb1d..4b0ff88b 100644 --- a/lang/ru-RU.json +++ b/lang/ru-RU.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Пароли не совпадают", "label.profile": "Профиль", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Обязательное", "label.settings": "Настройки", "label.this-month": "Этот месяц", diff --git a/lang/sv-SE.json b/lang/sv-SE.json index 7153a449..6133383b 100644 --- a/lang/sv-SE.json +++ b/lang/sv-SE.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Lösenorden är inte samma", "label.profile": "Profil", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Krävs", "label.settings": "Inställningar", "label.this-month": "Denna månad", diff --git a/lang/tr-TR.json b/lang/tr-TR.json index 8448303a..1aff9d2e 100644 --- a/lang/tr-TR.json +++ b/lang/tr-TR.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Parolalar uyuşmuyor", "label.profile": "Profil", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Zorunlu alan", "label.settings": "Ayarlar", "label.this-month": "Bu ay", diff --git a/lang/uk-UA.json b/lang/uk-UA.json index ce87cf68..27d04279 100644 --- a/lang/uk-UA.json +++ b/lang/uk-UA.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "Паролі не співпадають", "label.profile": "Профіль", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "Обов'язкове", "label.settings": "Налаштування", "label.this-month": "Поточний місяць", diff --git a/lang/zh-CN.json b/lang/zh-CN.json index 5b5653c5..18a82ac4 100644 --- a/lang/zh-CN.json +++ b/lang/zh-CN.json @@ -38,6 +38,7 @@ "label.passwords-dont-match": "密码不一致", "label.profile": "个人资料", "label.realtime": "Realtime", + "label.realtime-logs": "Realtime logs", "label.required": "必填", "label.settings": "设置", "label.this-month": "本月", diff --git a/package.json b/package.json index 3e2c5c15..1398c724 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "umami", - "version": "0.90.0", + "version": "0.91.0", "description": "A simple, fast, website analytics alternative to Google Analytics. ", "author": "Mike Cao ", "license": "MIT",