diff --git a/.gitignore b/.gitignore index 3fd77017..72ff2985 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ # production /build /public/umami.js +/lang-compiled # misc .DS_Store diff --git a/assets/globe.svg b/assets/globe.svg new file mode 100644 index 00000000..509eaba6 --- /dev/null +++ b/assets/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/common/LanguageButton.js b/components/common/LanguageButton.js new file mode 100644 index 00000000..93a09ec4 --- /dev/null +++ b/components/common/LanguageButton.js @@ -0,0 +1,42 @@ +import React, { useState, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Globe from 'assets/globe.svg'; +import useDocumentClick from 'hooks/useDocumentClick'; +import { updateApp } from 'redux/actions/app'; +import Menu from './Menu'; +import styles from './LanguageButton.module.css'; +import Button from './Button'; + +const menuOptions = [ + { label: 'English', value: 'en' }, + { label: '中文 (Chinese)', value: 'zh-CN' }, +]; + +export default function LanguageButton() { + const dispatch = useDispatch(); + const [showMenu, setShowMenu] = useState(false); + const locale = useSelector(state => state.app.locale); + const ref = useRef(); + const selectedLocale = menuOptions.find(e => e.value === locale)?.label.split(' ')[0]; + + function handleSelect(value) { + dispatch(updateApp({ locale: value })); + window.localStorage.setItem('locale', value); + setShowMenu(false); + } + + useDocumentClick(e => { + if (!ref.current.contains(e.target)) { + setShowMenu(false); + } + }); + + return ( +
+ + {showMenu && } +
+ ); +} diff --git a/components/common/LanguageButton.module.css b/components/common/LanguageButton.module.css new file mode 100644 index 00000000..b01f5d39 --- /dev/null +++ b/components/common/LanguageButton.module.css @@ -0,0 +1,5 @@ +.container { + display: flex; + position: relative; + cursor: pointer; +} diff --git a/components/common/UserButton.js b/components/common/UserButton.js index aed05d9c..483082a8 100644 --- a/components/common/UserButton.js +++ b/components/common/UserButton.js @@ -1,4 +1,5 @@ import React, { useState, useRef } from 'react'; +import { FormattedMessage } from 'react-intl'; import { useSelector } from 'react-redux'; import { useRouter } from 'next/router'; import Menu from './Menu'; @@ -17,14 +18,16 @@ export default function UserButton() { const menuOptions = [ { label: ( - <> - Logged in as {user.username} - + {user.username} }} + /> ), value: 'username', className: styles.username, }, - { label: 'Logout', value: 'logout' }, + { label: , value: 'logout' }, ]; function handleSelect(value) { diff --git a/components/layout/Footer.js b/components/layout/Footer.js index af0b82e6..a7957c2a 100644 --- a/components/layout/Footer.js +++ b/components/layout/Footer.js @@ -3,23 +3,26 @@ import { FormattedMessage } from 'react-intl'; import Link from 'next/link'; import classNames from 'classnames'; import Button from 'components/common/Button'; +import LanguageButton from 'components/common/LanguageButton'; import Logo from 'assets/logo.svg'; import styles from './Footer.module.css'; export default function Footer() { return ( ); diff --git a/components/layout/Footer.module.css b/components/layout/Footer.module.css index 0a46621d..ff5a26bb 100644 --- a/components/layout/Footer.module.css +++ b/components/layout/Footer.module.css @@ -1,14 +1,21 @@ .footer { display: flex; + justify-content: space-between; align-items: center; font-size: var(--font-size-small); min-height: 100px; } -.footer button { +.footer .center button { margin-left: 10px; } .footer a { text-decoration: none; } + +.center { + display: flex; + justify-content: center; + align-items: center; +} diff --git a/components/metrics/ActiveUsers.js b/components/metrics/ActiveUsers.js index 78d120eb..83ae00b8 100644 --- a/components/metrics/ActiveUsers.js +++ b/components/metrics/ActiveUsers.js @@ -1,5 +1,4 @@ import React, { useMemo } from 'react'; -import { useSpring, animated } from 'react-spring'; import classNames from 'classnames'; import useFetch from 'hooks/useFetch'; import styles from './ActiveUsers.module.css'; @@ -11,11 +10,6 @@ export default function ActiveUsers({ websiteId, className }) { return data?.[0]?.x || 0; }, [data]); - const props = useSpring({ - x: count, - from: { x: 0 }, - }); - if (count === 0) { return null; } @@ -24,14 +18,11 @@ export default function ActiveUsers({ websiteId, className }) {
- - {props.x.interpolate(x => x.toFixed(0))} -
diff --git a/lang/en.json b/lang/en.json index e559a846..769bd85f 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1,6 +1,6 @@ { "active-users.message": { - "defaultMessage": "current {count, plural, one {visitor} other {visitors}}" + "defaultMessage": "{x} current {x, plural, one {visitor} other {visitors}}" }, "button.add-account": { "defaultMessage": "Add account" @@ -74,6 +74,12 @@ "label.last-hours": { "defaultMessage": "Last {x} hours" }, + "label.logged-in-as": { + "defaultMessage": "Logged in as {username}" + }, + "label.logout": { + "defaultMessage": "Logout" + }, "label.name": { "defaultMessage": "Name" }, diff --git a/lang/zh-CN.json b/lang/zh-CN.json new file mode 100644 index 00000000..922db26a --- /dev/null +++ b/lang/zh-CN.json @@ -0,0 +1,233 @@ +{ + "active-users.message": { + "defaultMessage": "xx {x} current {x, plural, one {visitor} other {visitors}}" + }, + "button.add-account": { + "defaultMessage": "xx Add account" + }, + "button.add-website": { + "defaultMessage": "xx Add website" + }, + "button.back": { + "defaultMessage": "xx Back" + }, + "button.cancel": { + "defaultMessage": "xx Cancel" + }, + "button.change-password": { + "defaultMessage": "xx Change password" + }, + "button.copy-to-clipboard": { + "defaultMessage": "xx Copy to clipboard" + }, + "button.delete": { + "defaultMessage": "xx Delete" + }, + "button.edit": { + "defaultMessage": "xx Edit" + }, + "button.login": { + "defaultMessage": "xx Login" + }, + "button.more": { + "defaultMessage": "xx More" + }, + "button.save": { + "defaultMessage": "xx Save" + }, + "button.view-details": { + "defaultMessage": "xx View details" + }, + "footer.powered-by": { + "defaultMessage": "xx powered by" + }, + "header.nav.dashboard": { + "defaultMessage": "xx Dashboard" + }, + "header.nav.settings": { + "defaultMessage": "xx Settings" + }, + "label.adminsitrator": { + "defaultMessage": "xx Administrator" + }, + "label.confirm-password": { + "defaultMessage": "xx Confirm password" + }, + "label.current-password": { + "defaultMessage": "xx Current password" + }, + "label.domain": { + "defaultMessage": "xx Domain" + }, + "label.enable-share-url": { + "defaultMessage": "xx Enable share URL" + }, + "label.invalid": { + "defaultMessage": "xx Invalid" + }, + "label.invalid-domain": { + "defaultMessage": "xx Invalid domain" + }, + "label.last-days": { + "defaultMessage": "xx Last {x} days" + }, + "label.last-hours": { + "defaultMessage": "xx Last {x} hours" + }, + "label.logged-in-as": { + "defaultMessage": "xx Logged in as {username}" + }, + "label.logout": { + "defaultMessage": "xx Logout" + }, + "label.name": { + "defaultMessage": "xx Name" + }, + "label.new-password": { + "defaultMessage": "xx New password" + }, + "label.password": { + "defaultMessage": "xx Password" + }, + "label.passwords-dont-match": { + "defaultMessage": "xx Passwords don't match" + }, + "label.required": { + "defaultMessage": "xx Required" + }, + "label.this-month": { + "defaultMessage": "xx This month" + }, + "label.this-week": { + "defaultMessage": "xx This week" + }, + "label.this-year": { + "defaultMessage": "xx This year" + }, + "label.today": { + "defaultMessage": "xx Today" + }, + "label.username": { + "defaultMessage": "xx Username" + }, + "message.confirm-delete": { + "defaultMessage": "xx Are your sure you want to delete {target}?" + }, + "message.copied": { + "defaultMessage": "xx Copied!" + }, + "message.delete-warning": { + "defaultMessage": "xx All associated data will be deleted as well." + }, + "message.failure": { + "defaultMessage": "xx Something went wrong." + }, + "message.save-success": { + "defaultMessage": "xx Saved successfully." + }, + "message.share-url": { + "defaultMessage": "xx This is the publicly shared URL for {target}." + }, + "message.track-stats": { + "defaultMessage": "xx To track stats for {target}, place the following code in the {head} section of your website." + }, + "message.type-delete": { + "defaultMessage": "xx Type {delete} in the box below to confirm." + }, + "metrics.actions": { + "defaultMessage": "xx Actions" + }, + "metrics.average-visit-time": { + "defaultMessage": "xx Average visit time" + }, + "metrics.bounce-rate": { + "defaultMessage": "xx Bounce rate" + }, + "metrics.browsers": { + "defaultMessage": "xx Browsers" + }, + "metrics.countries": { + "defaultMessage": "xx Countries" + }, + "metrics.devices": { + "defaultMessage": "xx Devices" + }, + "metrics.events": { + "defaultMessage": "xx Events" + }, + "metrics.filter.combined": { + "defaultMessage": "xx Combined" + }, + "metrics.filter.domain-only": { + "defaultMessage": "xx Domain only" + }, + "metrics.filter.raw": { + "defaultMessage": "xx Raw" + }, + "metrics.operating-system": { + "defaultMessage": "xx Operating System" + }, + "metrics.page-views": { + "defaultMessage": "xx Page views" + }, + "metrics.pages": { + "defaultMessage": "xx Pages" + }, + "metrics.referrers": { + "defaultMessage": "xx Referrers" + }, + "metrics.unique-visitors": { + "defaultMessage": "xx Unique visitors" + }, + "metrics.views": { + "defaultMessage": "xx Views" + }, + "metrics.visitors": { + "defaultMessage": "xx Visitors" + }, + "placeholder.message.go-to-settings": { + "defaultMessage": "xx Go to settings" + }, + "placeholder.message.no-websites-configured": { + "defaultMessage": "xx You don't have any websites configured." + }, + "settings.accounts": { + "defaultMessage": "xx Accounts" + }, + "settings.profile": { + "defaultMessage": "xx Profile" + }, + "settings.websites": { + "defaultMessage": "xx Websites" + }, + "title.add-account": { + "defaultMessage": "xx Add account" + }, + "title.add-website": { + "defaultMessage": "xx Add website" + }, + "title.delete-account": { + "defaultMessage": "xx Delete account" + }, + "title.delete-website": { + "defaultMessage": "xx Delete website" + }, + "title.edit-account": { + "defaultMessage": "xx Edit account" + }, + "title.edit-website": { + "defaultMessage": "xx Edit website" + }, + "title.share-url": { + "defaultMessage": "xx Share URL" + }, + "title.tracking-code": { + "defaultMessage": "xx Tracking code" + }, + "tooltip.get-share-url": { + "defaultMessage": "xx Get share URL" + }, + "tooltip.get-tracking-code": { + "defaultMessage": "xx Get tracking code" + } +} \ No newline at end of file diff --git a/package.json b/package.json index 20f99bd7..bf0988be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "umami", - "version": "0.23.0", + "version": "0.24.0", "description": "A simple, fast, website analytics alternative to Google Analytics. ", "author": "Mike Cao ", "license": "MIT", @@ -11,7 +11,7 @@ }, "scripts": { "dev": "next dev", - "build": "npm-run-all build-tracker copy-db-schema build-db-client build-app", + "build": "npm-run-all build-tracker compile-lang copy-db-schema build-db-client build-app", "start": "next start", "build-app": "next build", "build-tracker": "rollup -c rollup.tracker.config.js", @@ -22,7 +22,8 @@ "build-mysql-client": "dotenv prisma generate -- --schema=./prisma/schema.mysql.prisma", "build-postgresql-schema": "dotenv prisma introspect -- --schema=./prisma/schema.postgresql.prisma", "build-postgresql-client": "dotenv prisma generate -- --schema=./prisma/schema.postgresql.prisma", - "extract": "formatjs extract --out-file lang/en.json components/**/*.js" + "extract-lang": "formatjs extract {pages,components}/**/*.js --out-file lang/en.json", + "compile-lang": "formatjs compile-folder --ast lang lang-compiled" }, "lint-staged": { "**/*.js": [ diff --git a/pages/_app.js b/pages/_app.js index 56f17385..710a7aa5 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -1,19 +1,45 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { IntlProvider } from 'react-intl'; -import { Provider } from 'react-redux'; +import { Provider, useDispatch, useSelector } from 'react-redux'; import { useStore } from 'redux/store'; import 'styles/variables.css'; import 'styles/bootstrap-grid.css'; import 'styles/index.css'; +import en from 'lang-compiled/en.json'; +import cn from 'lang-compiled/zh-CN.json'; +import { updateApp } from '../redux/actions/app'; + +const messages = { + en, + 'zh-CN': cn, +}; + +const Intl = ({ children }) => { + const dispatch = useDispatch(); + const locale = useSelector(state => state.app.locale); + + useEffect(() => { + const saved = localStorage.getItem('locale'); + if (saved) { + dispatch(updateApp({ locale: saved })); + } + }); + + return ( + + {children} + + ); +}; export default function App({ Component, pageProps }) { const store = useStore(); return ( - - + + - - + + ); } diff --git a/redux/actions/app.js b/redux/actions/app.js new file mode 100644 index 00000000..4cc28b3b --- /dev/null +++ b/redux/actions/app.js @@ -0,0 +1,16 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const app = createSlice({ + name: 'app', + initialState: { locale: 'en' }, + reducers: { + updateApp(state, action) { + state = action.payload; + return state; + }, + }, +}); + +export const { updateApp } = app.actions; + +export default app.reducer; diff --git a/redux/reducers.js b/redux/reducers.js index 7a4b6d7b..161bf134 100644 --- a/redux/reducers.js +++ b/redux/reducers.js @@ -1,6 +1,7 @@ import { combineReducers } from 'redux'; +import app from './actions/app'; import user from './actions/user'; import websites from './actions/websites'; import queries from './actions/queries'; -export default combineReducers({ user, websites, queries }); +export default combineReducers({ app, user, websites, queries });