Add locale support (#228)

This commit is contained in:
Luke Vella 2022-07-21 12:12:35 +01:00 committed by GitHub
parent 800af20132
commit 416a17c5b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 967 additions and 467 deletions

View file

@ -156,7 +156,7 @@ const Page: NextPage<CreatePollPageProps> = ({
<div className="max-w-full py-4 md:px-3 lg:px-6">
<div className="mx-auto w-fit max-w-full lg:mx-0">
<div className="mb-4 flex items-center justify-center space-x-4 px-4 lg:justify-start">
<h1 className="m-0">New Poll</h1>
<h1 className="m-0">{t("newPoll")}</h1>
<Steps current={currentStepIndex} total={steps.length} />
</div>
<div className="overflow-hidden border-t border-b bg-white shadow-sm md:rounded-lg md:border">

View file

@ -66,7 +66,7 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
);
}}
components={{
toolbar: (props) => {
toolbar: function Toolbar(props) {
return (
<DateNavigationToolbar
year={props.date.getFullYear()}
@ -83,7 +83,7 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
/>
);
},
eventWrapper: (props) => {
eventWrapper: function EventWraper(props) {
const start = dayjs(props.event.start);
const end = dayjs(props.event.end);
return (
@ -105,7 +105,7 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
},
week: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
header: ({ date }: any) => {
header: function Header({ date }: any) {
const dateString = formatDateWithoutTime(date);
const selectedOption = options.find((option) => {
return option.type === "date" && option.date === dateString;
@ -143,7 +143,7 @@ const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
);
},
},
timeSlotWrapper: ({ children }) => {
timeSlotWrapper: function TimeSlotWrapper({ children }) {
return <div className="h-8 text-xs text-gray-500">{children}</div>;
},
}}

View file

@ -10,7 +10,7 @@ import Ban from "./ban-ads.svg";
const Bonus: React.VoidFunctionComponent = () => {
const { t } = useTranslation("homepage");
return (
<div className="mx-auto max-w-7xl px-8 pt-8 pb-24">
<div className="mx-auto max-w-7xl px-8 py-8">
<h2 className="heading">{t("principles")}</h2>
<p className="subheading">{t("principlesSubheading")}</p>
<div className="grid grid-cols-4 gap-16">

View file

@ -26,12 +26,12 @@ const Hero: React.VoidFunctionComponent = () => {
</h1>
<div className="mb-12 text-xl text-gray-400">{t("heroSubText")}</div>
<div className="space-x-3">
<Link href="/new">
<Link href="/new" locale={false}>
<a className="rounded-lg bg-primary-500 px-5 py-3 font-semibold text-white shadow-sm transition-all hover:bg-primary-500/90 hover:text-white hover:no-underline hover:shadow-md focus:ring-2 focus:ring-primary-200 active:bg-primary-600/90">
{t("getStarted")}
</a>
</Link>
<Link href="/demo">
<Link href="/demo" locale={false}>
<a
className="rounded-lg bg-slate-500 px-5 py-3 font-semibold text-white shadow-sm transition-all hover:bg-slate-500/90 hover:text-white hover:no-underline hover:shadow-md focus:ring-2 focus:ring-primary-200 active:bg-slate-600/90"
rel="nofollow"

View file

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M14.06 3.44a1.5 1.5 0 0 1 0 2.12l-7 7a1.5 1.5 0 0 1-2.12 0l-3-3a1.5 1.5 0 1 1 2.12-2.12L6 9.378l5.94-5.94a1.5 1.5 0 0 1 2.12 0Z" clip-rule="evenodd" />
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>

Before

Width:  |  Height:  |  Size: 270 B

After

Width:  |  Height:  |  Size: 211 B

Before After
Before After

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.317 4.537a19.596 19.596 0 0 0-4.885-1.536.074.074 0 0 0-.079.038c-.21.38-.444.877-.608 1.267-1.845-.28-3.68-.28-5.487 0a12.891 12.891 0 0 0-.617-1.267A.077.077 0 0 0 8.562 3c-1.714.3-3.354.824-4.885 1.536a.07.07 0 0 0-.032.028C.533 9.278-.32 13.875.099 18.414a.084.084 0 0 0 .031.057 19.797 19.797 0 0 0 5.993 3.071.077.077 0 0 0 .084-.028c.462-.639.874-1.313 1.226-2.022a.078.078 0 0 0-.041-.107 13.021 13.021 0 0 1-1.872-.904.079.079 0 0 1-.008-.13c.126-.095.252-.195.372-.295a.073.073 0 0 1 .078-.01c3.927 1.817 8.18 1.817 12.061 0a.073.073 0 0 1 .079.009c.12.1.245.2.372.296a.079.079 0 0 1-.006.13 12.23 12.23 0 0 1-1.873.903.078.078 0 0 0-.041.108c.36.708.772 1.382 1.225 2.021a.076.076 0 0 0 .084.03 19.731 19.731 0 0 0 6.002-3.072.078.078 0 0 0 .032-.056c.5-5.248-.838-9.807-3.549-13.849a.061.061 0 0 0-.031-.029ZM8.02 15.65c-1.182 0-2.157-1.1-2.157-2.452 0-1.352.956-2.453 2.157-2.453 1.21 0 2.176 1.11 2.157 2.453 0 1.351-.956 2.452-2.157 2.452Zm7.975 0c-1.183 0-2.157-1.1-2.157-2.452 0-1.352.955-2.453 2.157-2.453 1.21 0 2.176 1.11 2.157 2.453 0 1.351-.946 2.452-2.157 2.452Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>

After

Width:  |  Height:  |  Size: 254 B

View file

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>

Before

Width:  |  Height:  |  Size: 463 B

After

Width:  |  Height:  |  Size: 540 B

Before After
Before After

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
</svg>

After

Width:  |  Height:  |  Size: 314 B

View file

@ -1,4 +1,5 @@
import clsx from "clsx";
import { useTranslation } from "next-i18next";
import * as React from "react";
import UserAvatar from "./poll/user-avatar";
@ -16,6 +17,7 @@ const NameInput: React.ForwardRefRenderFunction<
HTMLInputElement,
NameInputProps
> = ({ value, defaultValue, className, ...forwardProps }, ref) => {
const { t } = useTranslation("app");
return (
<div className="relative flex items-center">
<UserAvatar
@ -25,7 +27,7 @@ const NameInput: React.ForwardRefRenderFunction<
<input
ref={ref}
className={clsx("input pl-[35px]", className)}
placeholder="Your name…"
placeholder={t("yourName")}
value={value}
{...forwardProps}
/>

View file

@ -1,6 +1,5 @@
import clsx from "clsx";
import dynamic from "next/dynamic";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { Trans, useTranslation } from "next-i18next";
@ -24,6 +23,7 @@ const Menu: React.VoidFunctionComponent<{ className: string }> = ({
className,
}) => {
const { pathname } = useRouter();
const { t } = useTranslation("common");
return (
<nav className={className}>
<Link href="/">
@ -36,7 +36,7 @@ const Menu: React.VoidFunctionComponent<{ className: string }> = ({
},
)}
>
Home
{t("home")}
</a>
</Link>
<Link href="https://blog.rallly.co">
@ -45,14 +45,14 @@ const Menu: React.VoidFunctionComponent<{ className: string }> = ({
"text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2",
)}
>
Blog
{t("blog")}
</a>
</Link>
<a
href="https://support.rallly.co"
className="text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2"
>
Support
{t("support")}
</a>
<Link href="https://github.com/lukevella/rallly">
<a className="text-gray-400 transition-colors hover:text-primary-500 hover:no-underline hover:underline-offset-2">
@ -70,9 +70,6 @@ const PageLayout: React.VoidFunctionComponent<PageLayoutProps> = ({
const { t } = useTranslation("homepage");
return (
<div className="bg-pattern min-h-full overflow-x-hidden">
<Head>
<title>Rallly - Support</title>
</Head>
<div className="mx-auto flex max-w-7xl items-center py-8 px-8">
<div className="grow">
<div className="relative inline-block">

View file

@ -1,115 +1,151 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { Trans, useTranslation } from "next-i18next";
import * as React from "react";
import GitHubButton from "react-github-btn";
import Discord from "@/components/icons/discord.svg";
import Star from "@/components/icons/star.svg";
import Translate from "@/components/icons/translate.svg";
import Twitter from "@/components/icons/twitter.svg";
import DigitalOcean from "~/public/digitalocean.svg";
import Logo from "~/public/logo.svg";
import Sentry from "~/public/sentry.svg";
import Vercel from "~/public/vercel-logotype-dark.svg";
import { LanguageSelect } from "../poll/language-selector";
const Footer: React.VoidFunctionComponent = () => {
const { t } = useTranslation("homepage");
const { t } = useTranslation(["common", "homepage"]);
const router = useRouter();
return (
<div className="mt-16 bg-slate-50/70">
<div className="mx-auto grid max-w-7xl grid-cols-10 gap-8 py-20 px-8">
<div className="col-span-12 md:col-span-4">
<Logo className="mb-4 w-32 text-gray-400" />
<p className="text-sm text-gray-400">
<Trans
t={t}
i18nKey="footerCredit"
components={{
a: (
<a
className="font-normal leading-loose text-gray-400 hover:text-gray-800 hover:no-underline"
href="https://twitter.com/imlukevella"
/>
),
}}
/>
</p>
<div className="flex space-x-3">
<GitHubButton
<div className="mx-auto max-w-7xl space-y-8 p-8 lg:grid lg:grid-cols-12 lg:gap-16 lg:space-y-0">
<div className=" lg:col-span-4">
<Logo className="w-32 text-slate-400" />
<div className="mb-8 mt-4 text-slate-400">
<p>
<Trans
t={t}
i18nKey="common:footerSponsor"
components={{
a: (
<a
className="font-normal leading-loose text-slate-400 underline hover:text-slate-800 hover:underline"
href="https://www.paypal.com/donate/?hosted_button_id=7QXP2CUBLY88E"
/>
),
}}
/>
</p>
<div>
<Trans
t={t}
i18nKey="common:footerCredit"
components={{
a: (
<a
className="font-normal leading-loose text-slate-400 underline hover:text-slate-800 hover:underline"
href="https://twitter.com/imlukevella"
/>
),
}}
/>
</div>
</div>
<div className="mb-8 flex items-center space-x-6">
<a
href="https://twitter.com/ralllyco"
className="text-sm text-slate-400 transition-colors hover:text-primary-500 hover:no-underline"
>
<Twitter className="h-5 w-5" />
</a>
<a
href="https://discord.gg/m5UFXavc2C"
className="text-sm text-slate-400 transition-colors hover:text-primary-500 hover:no-underline"
>
<Discord className="h-5 w-5" />
</a>
<a
href="https://github.com/lukevella/rallly"
data-icon="octicon-star"
aria-label="Star lukevella/rallly on GitHub"
data-show-count={true}
className="inline-flex h-8 items-center rounded-full bg-slate-100 pl-2 pr-3 text-sm text-slate-400 transition-colors hover:bg-primary-500 hover:text-white hover:no-underline focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 active:bg-primary-600"
>
{t("star")}
</GitHubButton>
<GitHubButton
href="https://github.com/sponsors/lukevella"
data-icon="octicon-heart"
aria-label="Sponsor @lukevella on GitHub"
>
{t("sponsorThisProject")}
</GitHubButton>
<Star className="mr-2 inline-block w-5" />
<span>{t("common:starOnGithub")}</span>
</a>
</div>
</div>
<div className="col-span-6 md:col-span-2">
<div className="mb-4 font-medium">{t("links")}</div>
<div className="lg:col-span-2">
<div className="mb-4 font-medium">{t("homepage:links")}</div>
<ul>
<li>
<a
className="font-normal leading-loose text-gray-400 hover:text-gray-800 hover:no-underline"
className="font-normal leading-loose text-slate-400 hover:text-slate-800 hover:no-underline"
href="https://github.com/lukevella/rallly/discussions"
>
{t("discussions")}
{t("homepage:discussions")}
</a>
</li>
<li>
<Link href="https://blog.rallly.co">
<a className="font-normal leading-loose text-gray-400 hover:text-gray-800 hover:no-underline">
{t("blog")}
<a className="font-normal leading-loose text-slate-400 hover:text-slate-800 hover:no-underline">
{t("homepage:blog")}
</a>
</Link>
</li>
<li>
<a
href="https://support.rallly.co"
className="font-normal leading-loose text-gray-400 hover:text-gray-800 hover:no-underline"
className="font-normal leading-loose text-slate-400 hover:text-slate-800 hover:no-underline"
>
{t("support")}
{t("homepage:support")}
</a>
</li>
<li>
<Link href="/privacy-policy">
<a className="font-normal leading-loose text-gray-400 hover:text-gray-800 hover:no-underline">
{t("privacyPolicy")}
<a className="font-normal leading-loose text-slate-400 hover:text-slate-800 hover:no-underline">
{t("homepage:privacyPolicy")}
</a>
</Link>
</li>
</ul>
</div>
<div className="col-span-6 md:col-span-2">
<div className="mb-4 font-medium">{t("follow")}</div>
<ul>
<li>
<div className="lg:col-span-3">
<div className="mb-4 font-medium">{t("homepage:poweredBy")}</div>
<div className="block space-y-4">
<div>
<a
className="font-normal leading-loose text-gray-400 hover:text-gray-800 hover:no-underline"
href="https://github.com/lukevella/rallly"
href="https://vercel.com?utm_source=rallly&utm_campaign=oss"
className="inline-block text-white"
>
Github
<Vercel className="h-5" />
</a>
</li>
<li>
<a
className="font-normal leading-loose text-gray-400 hover:text-gray-800 hover:no-underline"
href="https://twitter.com/ralllyco"
>
Twitter
</div>
<div>
<a className="inline-block" href="https://m.do.co/c/f91efc9c9e50">
<DigitalOcean className="h-7" />
</a>
</li>
</ul>
</div>
<div>
<a className="inline-block" href="https://sentry.io">
<Sentry className="h-6" />
</a>
</div>
</div>
</div>
<div className="col-span-12 md:col-span-2">
<div className="lg:col-span-3">
<div className="mb-4 font-medium">{t("common:language")}</div>
<LanguageSelect
className="mb-4 w-full"
onChange={(locale) => {
router.push(router.asPath, router.asPath, { locale });
}}
/>
<a
href="https://vercel.com?utm_source=rallly&utm_campaign=oss"
className="inline-block text-white"
href="https://github.com/lukevella/rallly/wiki/Guide-for-translators"
className="inline-flex items-center rounded-md border px-3 py-2 text-xs text-slate-500"
>
<span className="mb-1 inline-block w-full text-right text-xs italic text-gray-400">
{t("poweredBy")}
</span>
<Vercel className="w-24" />
<Translate className="mr-2 h-5 w-5" />
{t("common:volunteerTranslator")} &rarr;
</a>
</div>
</div>

View file

@ -174,13 +174,6 @@ const PollPage: NextPage = () => {
<Sharing
onHide={() => {
setSharingVisible(false);
router.replace(
`/admin/${router.query.urlId}`,
undefined,
{
shallow: true,
},
);
}}
/>
</motion.div>

View file

@ -1,12 +1,14 @@
import { AnimatePresence, motion } from "framer-motion";
import { useTranslation } from "next-i18next";
import { Trans, useTranslation } from "next-i18next";
import * as React from "react";
import { useMeasure } from "react-use";
import smoothscroll from "smoothscroll-polyfill";
import ArrowLeft from "@/components/icons/arrow-left.svg";
import ArrowRight from "@/components/icons/arrow-right.svg";
import Check from "@/components/icons/check.svg";
import Plus from "@/components/icons/plus-sm.svg";
import { Button } from "../button";
import ArrowLeft from "../icons/arrow-left.svg";
import ArrowRight from "../icons/arrow-right.svg";
import { useParticipants } from "../participants-provider";
import { usePoll } from "../poll-context";
import TimeZonePicker from "../time-zone-picker";
@ -14,11 +16,10 @@ import ParticipantRow from "./desktop-poll/participant-row";
import ParticipantRowForm from "./desktop-poll/participant-row-form";
import { PollContext } from "./desktop-poll/poll-context";
import PollHeader from "./desktop-poll/poll-header";
import { useAddParticipantMutation } from "./mutations";
if (typeof window !== "undefined") {
smoothscroll.polyfill();
}
import {
useAddParticipantMutation,
useUpdateParticipantMutation,
} from "./mutations";
const MotionButton = motion(Button);
@ -27,7 +28,8 @@ const minSidebarWidth = 200;
const Poll: React.VoidFunctionComponent = () => {
const { t } = useTranslation("app");
const { poll, options, targetTimeZone, setTargetTimeZone } = usePoll();
const { poll, options, targetTimeZone, setTargetTimeZone, userAlreadyVoted } =
usePoll();
const { participants } = useParticipants();
@ -35,7 +37,7 @@ const Poll: React.VoidFunctionComponent = () => {
const [editingParticipantId, setEditingParticipantId] =
React.useState<string | null>(null);
const actionColumnWidth = 140;
const actionColumnWidth = 100;
const columnWidth = Math.min(
130,
Math.max(
@ -65,7 +67,8 @@ const Poll: React.VoidFunctionComponent = () => {
const maxScrollPosition =
columnWidth * options.length - columnWidth * numberOfVisibleColumns;
const shouldShowNewParticipantForm = !poll.closed;
const [shouldShowNewParticipantForm, setShouldShowNewParticipantForm] =
React.useState(!poll.closed && !userAlreadyVoted);
const pollWidth =
sidebarWidth + options.length * columnWidth + actionColumnWidth;
@ -87,6 +90,8 @@ const Poll: React.VoidFunctionComponent = () => {
);
};
const updateParticipant = useUpdateParticipantMutation();
const participantListContainerRef = React.useRef<HTMLDivElement>(null);
return (
<PollContext.Provider
@ -192,28 +197,93 @@ const Poll: React.VoidFunctionComponent = () => {
isEditing ? participant.id : null,
);
}}
onSubmit={async ({ name, votes }) => {
await updateParticipant.mutateAsync({
participantId: participant.id,
pollId: poll.id,
votes,
name,
});
}}
/>
);
})}
</div>
) : null}
{shouldShowNewParticipantForm ? (
{shouldShowNewParticipantForm &&
!poll.closed &&
!editingParticipantId ? (
<ParticipantRowForm
className="border-t bg-gray-50"
className="shrink-0 border-t bg-gray-50"
onSubmit={async ({ name, votes }) => {
const participant = await addParticipant.mutateAsync({
await addParticipant.mutateAsync({
name,
votes,
pollId: poll.id,
});
setTimeout(() => {
participantListContainerRef.current
?.querySelector(`[data-participantid=${participant.id}]`)
?.scrollIntoView();
}, 100);
setShouldShowNewParticipantForm(false);
}}
/>
) : null}
{!poll.closed ? (
<div className="flex h-14 shrink-0 items-center border-t bg-gray-50 px-3">
{shouldShowNewParticipantForm || editingParticipantId ? (
<div className="flex items-center space-x-3">
<Button
key="submit"
form="participant-row-form"
htmlType="submit"
type="primary"
icon={<Check />}
loading={
addParticipant.isLoading || updateParticipant.isLoading
}
>
{t("save")}
</Button>
<Button
onClick={() => {
if (editingParticipantId) {
setEditingParticipantId(null);
} else {
setShouldShowNewParticipantForm(false);
}
}}
>
{t("cancel")}
</Button>
<div className="text-sm">
<Trans
t={t}
i18nKey="saveInstruction"
values={{
save: t("save"),
}}
components={{ b: <strong /> }}
/>
</div>
</div>
) : (
<div className="flex w-full items-center space-x-3">
<Button
key="add-participant"
onClick={() => {
setShouldShowNewParticipantForm(true);
}}
icon={<Plus />}
>
{t("addParticipant")}
</Button>
{userAlreadyVoted ? (
<div className="flex items-center text-sm text-gray-400">
<Check className="mr-1 h-5" />
<div>{t("alreadyVoted")}</div>
</div>
) : null}
</div>
)}
</div>
) : null}
</div>
</div>
</PollContext.Provider>

View file

@ -1,11 +1,10 @@
import clsx from "clsx";
import { AnimatePresence, motion } from "framer-motion";
import { useTranslation } from "next-i18next";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import CompactButton from "@/components/compact-button";
import Check from "@/components/icons/check.svg";
import X from "@/components/icons/x.svg";
import ArrowRight from "@/components/icons/arrow-right.svg";
import { requiredString } from "../../../utils/form-validation";
import { Button } from "../../button";
@ -17,6 +16,7 @@ import { VoteSelector } from "../vote-selector";
import ControlledScrollArea from "./controlled-scroll-area";
import { usePollContext } from "./poll-context";
const MotionButton = motion(Button);
export interface ParticipantRowFormProps {
defaultValues?: Partial<ParticipantForm>;
onSubmit: (data: ParticipantFormSubmitted) => Promise<void>;
@ -43,7 +43,7 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
const {
handleSubmit,
control,
formState: { errors, submitCount, isSubmitting },
formState: { errors, submitCount },
reset,
} = useForm({
defaultValues: {
@ -69,6 +69,7 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
return (
<form
id="participant-row-form"
ref={ref}
onSubmit={handleSubmit(async ({ name, votes }) => {
await onSubmit({
@ -91,7 +92,7 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
className={clsx("w-full", {
"input-error": errors.name && submitCount > 0,
})}
placeholder="Your name"
placeholder={t("yourName")}
{...field}
onKeyDown={(e) => {
if (e.code === "Tab" && scrollPosition > 0) {
@ -126,7 +127,7 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
return (
<div
key={optionId}
className="flex shrink-0 items-center justify-center"
className="flex shrink-0 items-center justify-center px-2"
style={{ width: columnWidth }}
>
<VoteSelector
@ -162,28 +163,25 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
/>
<div className="flex items-center space-x-2 px-2 transition-all">
{scrollPosition >= maxScrollPosition ? (
<Button
htmlType="submit"
icon={<Check />}
type="primary"
loading={isSubmitting}
data-testid="submitNewParticipant"
>
{t("save")}
</Button>
) : null}
{scrollPosition < maxScrollPosition ? (
<Button
onClick={(e) => {
e.stopPropagation();
goToNextPage();
}}
>
{t("next")} &rarr;
</Button>
<AnimatePresence initial={false}>
{scrollPosition < maxScrollPosition ? (
<MotionButton
transition={{ duration: 0.1 }}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="text-xs"
rounded={true}
onClick={() => {
goToNextPage();
}}
>
<ArrowRight className="h-4 w-4" />
</MotionButton>
) : null}
</AnimatePresence>
) : null}
{onCancel ? <CompactButton onClick={onCancel} icon={X} /> : null}
</div>
</form>
);

View file

@ -8,7 +8,7 @@ import Trash from "@/components/icons/trash.svg";
import { usePoll } from "@/components/poll-context";
import { useSession } from "@/components/session";
import { useUpdateParticipantMutation } from "../mutations";
import { ParticipantFormSubmitted } from "../types";
import { useDeleteParticipantModal } from "../use-delete-participant-modal";
import UserAvatar from "../user-avatar";
import VoteIcon from "../vote-icon";
@ -18,8 +18,9 @@ import { usePollContext } from "./poll-context";
export interface ParticipantRowProps {
participant: Participant & { votes: Vote[] };
editMode: boolean;
onChangeEditMode: (value: boolean) => void;
editMode?: boolean;
onChangeEditMode?: (editMode: boolean) => void;
onSubmit?: (data: ParticipantFormSubmitted) => Promise<void>;
}
export const ParticipantRowView: React.VoidFunctionComponent<{
@ -49,7 +50,7 @@ export const ParticipantRowView: React.VoidFunctionComponent<{
<div
data-testid="participant-row"
data-participantid={participantId}
className="group flex h-14"
className="group flex h-14 items-center"
>
<div
className="flex shrink-0 items-center px-4"
@ -74,12 +75,12 @@ export const ParticipantRowView: React.VoidFunctionComponent<{
return (
<div
key={i}
className="relative shrink-0 transition-colors"
className="relative flex shrink-0 items-center justify-center px-2 transition-colors"
style={{ width: columnWidth }}
>
<div
className={clsx(
"absolute inset-1 flex items-center justify-center rounded-lg",
"flex h-10 w-full items-center justify-center rounded-md",
{
"bg-green-50": vote === "yes",
"bg-amber-50": vote === "ifNeedBe",
@ -100,12 +101,11 @@ export const ParticipantRowView: React.VoidFunctionComponent<{
const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
participant,
editMode,
onSubmit,
onChangeEditMode,
}) => {
const { columnWidth, sidebarWidth } = usePollContext();
const updateParticipant = useUpdateParticipantMutation();
const confirmDeleteParticipant = useDeleteParticipantModal();
const session = useSession();
@ -128,12 +128,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
}),
}}
onSubmit={async ({ name, votes }) => {
await updateParticipant.mutateAsync({
participantId: participant.id,
pollId: poll.id,
votes,
name,
});
await onSubmit?.({ name, votes });
onChangeEditMode?.(false);
}}
onCancel={() => onChangeEditMode?.(false)}

View file

@ -0,0 +1,27 @@
import clsx from "clsx";
import Cookies from "js-cookie";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
export const LanguageSelect: React.VoidFunctionComponent<{
className?: string;
onChange?: (language: string) => void;
}> = ({ className, onChange }) => {
const { t } = useTranslation("common");
const router = useRouter();
return (
<select
className={clsx("input", className)}
defaultValue={router.locale}
onChange={(e) => {
Cookies.set("NEXT_LOCALE", e.target.value, {
expires: 365,
});
onChange?.(e.target.value);
}}
>
<option value="en">{t("english")}</option>
<option value="de">{t("german")}</option>
</select>
);
};

View file

@ -213,16 +213,18 @@ const ManagePoll: React.VoidFunctionComponent<{
onClick={() => {
modalContext.render({
overlayClosable: true,
content: ({ close }) => (
<DeletePollForm
onConfirm={async () => {
close();
setDeleted(true);
}}
onCancel={close}
urlId={urlId}
/>
),
content: function Content({ close }) {
return (
<DeletePollForm
onConfirm={async () => {
close();
setDeleted(true);
}}
onCancel={close}
urlId={urlId}
/>
);
},
footer: null,
});
}}

View file

@ -229,6 +229,7 @@ const PollOption: React.VoidFunctionComponent<PollOptionProps> = ({
ref={selectorRef}
value={vote}
onChange={onChange}
className="w-9"
/>
</div>
) : (

View file

@ -29,6 +29,10 @@ export const useAddParticipantMutation = () => {
return [...existingParticipants, participant];
},
);
queryClient.invalidateQueries([
"polls.participants.list",
{ pollId: participant.pollId },
]);
session.refresh();
},
});

View file

@ -1,4 +1,5 @@
import { VoteType } from "@prisma/client";
import clsx from "clsx";
import { AnimatePresence, motion } from "framer-motion";
import * as React from "react";
@ -10,6 +11,7 @@ export interface VoteSelectorProps {
onFocus?: React.FocusEventHandler<HTMLButtonElement>;
onBlur?: React.FocusEventHandler<HTMLButtonElement>;
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
className?: string;
}
const orderedVoteTypes: VoteType[] = ["yes", "ifNeedBe", "no"];
@ -23,7 +25,10 @@ const getNext = (value: VoteType) => {
export const VoteSelector = React.forwardRef<
HTMLButtonElement,
VoteSelectorProps
>(function VoteSelector({ value, onChange, onFocus, onBlur, onKeyDown }, ref) {
>(function VoteSelector(
{ value, onChange, onFocus, onBlur, onKeyDown, className },
ref,
) {
return (
<button
data-testid="vote-selector"
@ -31,7 +36,20 @@ export const VoteSelector = React.forwardRef<
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
className="focus-visible:ring-primary-500 relative inline-flex h-9 w-9 items-center justify-center rounded-lg border bg-white shadow-sm transition focus-visible:border-0 focus-visible:ring-2 active:scale-95"
className={clsx(
"group relative inline-flex h-9 w-full items-center justify-center overflow-hidden rounded-md border bg-white transition-all hover:ring-4 focus-visible:border-0 focus-visible:ring-2 focus-visible:ring-primary-500",
{
"border-green-200 bg-green-50 hover:ring-green-100/50 active:bg-green-100/50":
value === "yes",
"border-amber-200 bg-amber-50 hover:ring-amber-100/50 active:bg-amber-100/50":
value === "ifNeedBe",
"border-gray-200 bg-gray-50 hover:ring-gray-100/50 active:bg-gray-100/50":
value === "no",
"border-gray-200 hover:ring-gray-100/50 active:bg-gray-100/50":
value === undefined,
},
className,
)}
onClick={() => {
onChange?.(value ? getNext(value) : orderedVoteTypes[0]);
}}
@ -40,10 +58,10 @@ export const VoteSelector = React.forwardRef<
<AnimatePresence initial={false}>
<motion.span
className="absolute flex items-center justify-center"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, y: -15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 15 }}
transition={{ duration: 0.2 }}
initial={{ opacity: 0, scale: 1.5, y: -45 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.5, y: 45 }}
key={value}
>
<VoteIcon type={value} />

View file

@ -1,29 +1,33 @@
import clsx from "clsx";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import { usePlausible } from "next-plausible";
import React from "react";
import Calendar from "@/components/icons/calendar.svg";
import { LanguageSelect } from "./poll/language-selector";
import { usePreferences } from "./preferences/use-preferences";
const Preferences: React.VoidFunctionComponent = () => {
const { t } = useTranslation("app");
const { t } = useTranslation(["app", "common"]);
const { weekStartsOn, setWeekStartsOn, timeFormat, setTimeFormat } =
usePreferences();
const router = useRouter();
const plausible = usePlausible();
return (
<div className="-mb-2">
<div className="mb-4 flex items-center space-x-2 text-base font-semibold">
<Calendar className="inline-block w-5" />
<span>{t("timeAndDate")}</span>
<div>
<div className="mb-4 space-y-2">
<div className="grow text-sm text-slate-500">
{t("common:language")}
</div>
<LanguageSelect className="w-full" onChange={() => router.reload()} />
</div>
<div className="grow">
<div className="mb-2">
<div className="grow space-y-2">
<div>
<div className="mb-2 grow text-sm text-slate-500">
{t("weekStartsOn")}
{t("app:weekStartsOn")}
</div>
<div>
<div className="segment-button inline-flex">
@ -41,7 +45,7 @@ const Preferences: React.VoidFunctionComponent = () => {
}}
type="button"
>
{t("monday")}
{t("app:monday")}
</button>
<button
className={clsx({
@ -57,14 +61,14 @@ const Preferences: React.VoidFunctionComponent = () => {
}}
type="button"
>
{t("sunday")}
{t("app:sunday")}
</button>
</div>
</div>
</div>
<div className="mb-2">
<div className="">
<div className="mb-2 grow text-sm text-slate-500">
{t("timeFormat")}
{t("app:timeFormat")}
</div>
<div className="segment-button inline-flex">
<button
@ -81,7 +85,7 @@ const Preferences: React.VoidFunctionComponent = () => {
}}
type="button"
>
{t("12h")}
{t("app:12h")}
</button>
<button
className={clsx({
@ -97,7 +101,7 @@ const Preferences: React.VoidFunctionComponent = () => {
}}
type="button"
>
{t("24h")}
{t("app:24h")}
</button>
</div>
</div>

View file

@ -1,4 +1,5 @@
import dayjs from "dayjs";
import de from "dayjs/locale/de";
import en from "dayjs/locale/en";
import duration from "dayjs/plugin/duration";
import isBetween from "dayjs/plugin/isBetween";
@ -9,12 +10,18 @@ import minMax from "dayjs/plugin/minMax";
import relativeTime from "dayjs/plugin/relativeTime";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { useRouter } from "next/router";
import * as React from "react";
import { useLocalStorage } from "react-use";
type TimeFormat = "12h" | "24h";
type StartOfWeek = "monday" | "sunday";
const dayJsLocales = {
de,
en,
};
dayjs.extend(localizedFormat);
dayjs.extend(relativeTime);
dayjs.extend(localeData);
@ -43,11 +50,13 @@ const PreferencesProvider: React.VoidFunctionComponent<{
const [weekStartsOn = "monday", setWeekStartsOn] =
useLocalStorage<StartOfWeek>("rallly-week-starts-on");
const router = useRouter();
const userLocale = dayJsLocales[router.locale ?? "en"];
const [timeFormat = "12h", setTimeFormat] =
useLocalStorage<TimeFormat>("rallly-time-format");
dayjs.locale({
...en,
...userLocale,
weekStart: weekStartsOn === "monday" ? 1 : 0,
formats: { LT: timeFormat === "12h" ? "h:mm A" : "HH:mm" },
});

View file

@ -12,6 +12,7 @@ import Logo from "~/public/logo.svg";
import Dropdown, { DropdownItem, DropdownProps } from "./dropdown";
import Adjustments from "./icons/adjustments.svg";
import Cash from "./icons/cash.svg";
import Discord from "./icons/discord.svg";
import DotsVertical from "./icons/dots-vertical.svg";
import Github from "./icons/github.svg";
import Login from "./icons/login.svg";
@ -293,7 +294,7 @@ const StandardLayout: React.VoidFunctionComponent<{
<AnimatePresence initial={false}>
{user ? (
<UserDropdown
className="w-full"
className="mb-4 w-full"
placement="bottom-end"
openLoginModal={openLoginModal}
trigger={
@ -358,25 +359,34 @@ const StandardLayout: React.VoidFunctionComponent<{
</Link>
<div className="hidden text-slate-300 lg:block">&bull;</div>
<div className="flex items-center space-x-6">
<Link href="https://twitter.com/ralllyco">
<a className="text-sm text-slate-400 transition-colors hover:text-primary-500 hover:no-underline">
<Twitter className="h-5 w-5" />
</a>
</Link>
<Link href="https://github.com/lukevella/rallly">
<a className="text-sm text-slate-400 transition-colors hover:text-primary-500 hover:no-underline">
<Github className="h-5 w-5" />
</a>
</Link>
<a
href="https://twitter.com/ralllyco"
className="text-sm text-slate-400 transition-colors hover:text-primary-500 hover:no-underline"
>
<Twitter className="h-5 w-5" />
</a>
<a
href="https://github.com/lukevella/rallly"
className="text-sm text-slate-400 transition-colors hover:text-primary-500 hover:no-underline"
>
<Github className="h-5 w-5" />
</a>
<a
href="https://discord.gg/m5UFXavc2C"
className="text-sm text-slate-400 transition-colors hover:text-primary-500 hover:no-underline"
>
<Discord className="h-5 w-5" />
</a>
</div>
</div>
<div className="hidden text-slate-300 lg:block">&bull;</div>
<Link href="https://www.paypal.com/donate/?hosted_button_id=7QXP2CUBLY88E">
<a className="inline-flex h-8 items-center rounded-full bg-slate-100 pl-2 pr-3 text-sm text-slate-400 transition-colors hover:bg-primary-500 hover:text-white hover:no-underline focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 active:bg-primary-600">
<Cash className="mr-1 inline-block w-5" />
<span>{t("donate")}</span>
</a>
</Link>
<a
href="https://www.paypal.com/donate/?hosted_button_id=7QXP2CUBLY88E"
className="inline-flex h-8 items-center rounded-full bg-slate-100 pl-2 pr-3 text-sm text-slate-400 transition-colors hover:bg-primary-500 hover:text-white hover:no-underline focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 active:bg-primary-600"
>
<Cash className="mr-1 inline-block w-5" />
<span>{t("donate")}</span>
</a>
</div>
</div>
</div>